@fennecstudio/x-tkn-mcp 0.0.0-stage → 1.0.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 +129 -2
  2. package/dist/index.js +921 -0
  3. package/package.json +47 -4
package/README.md CHANGED
@@ -1,3 +1,130 @@
1
- # Temporary Holding Version
1
+ # X-TKN MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lets an AI agent hand a secret to a person as a one-time link, with no way to read it back.
4
+
5
+ ```
6
+ npx -y @fennecstudio/x-tkn-mcp
7
+ ```
8
+
9
+ - **No read tool.** The server has `create_secret`, `check_status` and
10
+ `revoke_secret`. Once a secret is handed over, the agent can see whether it
11
+ was opened and can cancel it. It cannot see the secret again.
12
+ - **Encrypted on your machine.** `create_secret` encrypts with AES-256-GCM
13
+ before anything is sent. X-TKN stores only ciphertext. The key travels in the
14
+ link's `#fragment`, which browsers never send to a server.
15
+ - **Up to 256 KB per secret**: 64 KB on the free Developer plan, 256 KB on Team
16
+ and Enterprise. Enough for a certificate bundle or a `.env` file, not just a
17
+ password.
18
+
19
+ It runs locally over stdio for a reason. A hosted MCP server would receive the
20
+ plaintext before encrypting it, and the whole point is that nobody but the
21
+ recipient can read it.
22
+
23
+ ## Setup
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.
28
+
29
+ **Claude Code**
30
+
31
+ ```bash
32
+ claude mcp add x-tkn -e X_TKN_API_KEY=your-key -- npx -y @fennecstudio/x-tkn-mcp
33
+ ```
34
+
35
+ **Claude Desktop, Cursor, Windsurf and other clients** take the same block in
36
+ their MCP config file:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "x-tkn": {
42
+ "command": "npx",
43
+ "args": ["-y", "@fennecstudio/x-tkn-mcp"],
44
+ "env": { "X_TKN_API_KEY": "your-key" }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Without a key the server still starts, and every tool call answers with how to
51
+ get one.
52
+
53
+ ## Tools
54
+
55
+ ### `create_secret`
56
+
57
+ Encrypts a secret and returns a link that opens it once.
58
+
59
+ | Argument | Required | What it does |
60
+ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
61
+ | `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. |
63
+ | `passphrase` | no | The recipient has to type this before the secret opens. 8 to 256 characters. Team and above. |
64
+ | `recipientEmail` | no | The recipient has to enter a 6-digit code sent to this address. Team and above. Not with `passphrase`. |
65
+ | `note` | no | For your own records in the X-TKN console. Stored unencrypted and never shown to the recipient. |
66
+
67
+ Returns the `link`, a `code` for the other two tools, and `expiresAt`.
68
+
69
+ Send a passphrase by a different channel than the link. Anyone holding both can
70
+ open the secret.
71
+
72
+ ### `check_status`
73
+
74
+ Takes the `code`. Returns `waiting`, `opened`, `expired` or `revoked`, with
75
+ `openedAt` once it has been opened. It never returns the secret.
76
+
77
+ ### `revoke_secret`
78
+
79
+ Takes the `code`. Stops the link working, immediately and for good. A secret
80
+ that was already opened stays opened.
81
+
82
+ ## What X-TKN sees
83
+
84
+ The ciphertext, the expiry, your `note`, and, if you set them, a hash of the
85
+ passphrase or the recipient's email address. The passphrase gates the link and
86
+ plays no part in the encryption. X-TKN never receives the plaintext or the key.
87
+
88
+ Opening the link uses it up, so give it only to the person it is for. The
89
+ retrieval page takes the key out of the address bar after it decrypts the
90
+ secret. If opening fails, the full link, key included, stays in that browser's
91
+ history.
92
+
93
+ ## Publishing
94
+
95
+ For whoever ships a release. Run these in order, from this directory. The
96
+ registry steps read what npm holds, so none of them can come before the first.
97
+
98
+ Keep one version everywhere: `version` in `package.json`, `VERSION` in
99
+ `src/version.ts`, and both `version` fields in `server.json`. Specs fail when
100
+ they disagree. Keep `server.json`'s `description` equal to the first paragraph
101
+ of this README, and no longer than 100 characters, which is the registry's limit.
102
+
103
+ 1. **npm.** `npm run deploy -- --otp=<code>`. That is `npm publish`, and
104
+ `prepublishOnly` typechecks, tests and builds first. It goes first because
105
+ the registry reads `mcpName` out of the published package, and npm will not
106
+ take the same version twice.
107
+ 2. **Official MCP Registry.** Install the CLI with `brew install mcp-publisher`,
108
+ then:
109
+
110
+ ```bash
111
+ mcp-publisher login github
112
+ mcp-publisher publish
113
+ ```
114
+
115
+ Log in as an Owner of the `fennecstudio` GitHub organization. The registry
116
+ grants `io.github.fennecstudio/*` to its owners only, and an ordinary member
117
+ is refused. Check it landed with
118
+ `curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.fennecstudio/x-tkn-mcp"`.
119
+ 3. **Smithery.** `smithery mcp publish`. It does not run bare: Smithery takes a
120
+ server URL or a pre-built `.mcpb` bundle, and `-n <org>/<server>`. This
121
+ package serves no URL and builds no bundle, so read `smithery mcp publish --help`
122
+ and Smithery's publishing docs for the current form before running it.
123
+ 4. **Glama and PulseMCP.** Both crawl on their own, so there is nothing to
124
+ submit. Once the listing appears, claim it on each site.
125
+
126
+ ## Links
127
+
128
+ - [x-tkn.com](https://x-tkn.com)
129
+ - API reference: [x-tkn.com/docs](https://x-tkn.com/docs)
130
+ - JavaScript SDK: [`@fennecstudio/x-tkn-js`](https://www.npmjs.com/package/@fennecstudio/x-tkn-js)
package/dist/index.js ADDED
@@ -0,0 +1,921 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // ../js/src/errors.ts
7
+ var XTknError = class extends Error {
8
+ /** HTTP status. `0` when the request never got a response. */
9
+ status;
10
+ /** Field-level errors, when the API supplied them. */
11
+ details;
12
+ /** Parsed response body, for anything the typed fields do not cover. */
13
+ body;
14
+ constructor(message, status2, details, body) {
15
+ super(message);
16
+ this.name = new.target.name;
17
+ this.status = status2;
18
+ this.details = details;
19
+ this.body = body;
20
+ Object.setPrototypeOf(this, new.target.prototype);
21
+ }
22
+ };
23
+ var XTknRequestError = class extends XTknError {
24
+ };
25
+ var XTknAuthError = class extends XTknError {
26
+ };
27
+ var XTknPaymentRequiredError = class extends XTknError {
28
+ /**
29
+ * A link a human can act on — Stripe Checkout, or the Billing Portal when the
30
+ * account already has a subscription. See {@link upgradeMethod}.
31
+ *
32
+ * `undefined` when the API could not build one — Stripe unreachable or slow,
33
+ * or no price configured for the plan — and *always* undefined on an
34
+ * unclaimed sandbox account, whose next step is {@link claimEndpoint} rather
35
+ * than a payment. The refusal is still correct; there is just no link to
36
+ * print. Always handle that case: it is the difference between telling
37
+ * someone what to do and printing "undefined".
38
+ */
39
+ get upgradeUrl() {
40
+ return this.details?.upgradeUrl;
41
+ }
42
+ /**
43
+ * How this account gets unblocked: `'checkout'` to buy a first subscription,
44
+ * `'portal'` to change an existing one, `'claim'` to turn an unclaimed
45
+ * sandbox into a real account first.
46
+ *
47
+ * A second subscription would bill twice, so an account that already pays us
48
+ * changes plan in the portal instead. Both of those are a link a human opens.
49
+ * `'claim'` is the one that is not: there is no {@link upgradeUrl}, and the
50
+ * next step is a call your own code makes to {@link claimEndpoint}. Branch on
51
+ * this rather than assuming a URL is always there.
52
+ */
53
+ get upgradeMethod() {
54
+ return this.details?.upgradeMethod;
55
+ }
56
+ /**
57
+ * Where to claim an unclaimed sandbox account, when {@link upgradeMethod} is
58
+ * `'claim'`. Undefined otherwise.
59
+ *
60
+ * A sandbox is anonymous and reaped within the day, so it cannot hold a
61
+ * subscription — claiming it is what makes it an account worth buying a plan
62
+ * for. Claiming grants an ordinary free account, not a paid one, so the plan
63
+ * in {@link requiredPlan} is still needed afterwards.
64
+ */
65
+ get claimEndpoint() {
66
+ return this.details?.claimEndpoint;
67
+ }
68
+ /**
69
+ * A sentence explaining the claim step, worth showing a human verbatim.
70
+ * Undefined unless {@link upgradeMethod} is `'claim'`.
71
+ */
72
+ get claimReason() {
73
+ return this.details?.claimReason;
74
+ }
75
+ /**
76
+ * @deprecated No longer sent, so always `undefined`. It carried
77
+ * {@link upgradeUrl} wrapped as an X-TKN link, which the retrieval page could
78
+ * not open. Use {@link upgradeUrl}.
79
+ */
80
+ get upgradeToken() {
81
+ return this.details?.upgradeToken;
82
+ }
83
+ /** The entitlement that was missing, e.g. `recipientAuth`. */
84
+ get capability() {
85
+ return this.details?.capability;
86
+ }
87
+ /** The cheapest plan granting it — `team` or `enterprise`. */
88
+ get requiredPlan() {
89
+ return this.details?.requiredPlan;
90
+ }
91
+ /**
92
+ * {@link upgradeUrl} works until at least this time. Present only when
93
+ * {@link upgradeMethod} is `'checkout'`.
94
+ */
95
+ get upgradeExpiresAt() {
96
+ return this.details?.upgradeExpiresAt;
97
+ }
98
+ };
99
+ var XTknNotFoundError = class extends XTknError {
100
+ };
101
+ var XTknGoneError = class extends XTknError {
102
+ };
103
+ var XTknRateLimitError = class extends XTknError {
104
+ };
105
+ var XTknServerError = class extends XTknError {
106
+ };
107
+ var XTknConnectionError = class extends XTknError {
108
+ constructor(message, cause) {
109
+ super(message, 0);
110
+ this.cause = cause;
111
+ }
112
+ };
113
+ var XTknConfigError = class extends XTknError {
114
+ constructor(message) {
115
+ super(message, 0);
116
+ }
117
+ };
118
+ function errorForStatus(status2) {
119
+ if (status2 === 400) return XTknRequestError;
120
+ if (status2 === 401 || status2 === 403) return XTknAuthError;
121
+ if (status2 === 402) return XTknPaymentRequiredError;
122
+ if (status2 === 404) return XTknNotFoundError;
123
+ if (status2 === 410) return XTknGoneError;
124
+ if (status2 === 429) return XTknRateLimitError;
125
+ if (status2 >= 500) return XTknServerError;
126
+ return XTknError;
127
+ }
128
+ function errorFromResponse(status2, body, statusText) {
129
+ const record = body && typeof body === "object" ? body : void 0;
130
+ const message = typeof record?.error === "string" && record.error || typeof record?.message === "string" && record.message || statusText || `Request failed with status ${status2}`;
131
+ const details = record?.details && typeof record.details === "object" ? record.details : void 0;
132
+ const Ctor = errorForStatus(status2);
133
+ return new Ctor(message, status2, details, body);
134
+ }
135
+
136
+ // ../js/src/duration.ts
137
+ var MS = {
138
+ seconds: 1e3,
139
+ minutes: 6e4,
140
+ hours: 36e5,
141
+ days: 864e5
142
+ };
143
+ function durationToMs(duration) {
144
+ let total = 0;
145
+ let seen = false;
146
+ for (const unit of ["days", "hours", "minutes", "seconds"]) {
147
+ const value = duration?.[unit];
148
+ if (value === void 0 || value === null) continue;
149
+ if (typeof value !== "number" || !Number.isFinite(value)) {
150
+ throw new XTknConfigError(`Duration field "${unit}" must be a finite number`);
151
+ }
152
+ total += value * MS[unit];
153
+ seen = true;
154
+ }
155
+ if (!seen) {
156
+ throw new XTknConfigError("Duration must set at least one of days, hours, minutes or seconds");
157
+ }
158
+ return total;
159
+ }
160
+ function durationToSeconds(duration) {
161
+ return Math.round(durationToMs(duration) / 1e3);
162
+ }
163
+ function expiryFromDuration(duration, from = /* @__PURE__ */ new Date()) {
164
+ return new Date(from.getTime() + durationToMs(duration));
165
+ }
166
+ function resolveExpiry(expiresAt, expiresIn2) {
167
+ if (expiresAt !== void 0 && expiresIn2 !== void 0) {
168
+ throw new XTknConfigError("Pass either expiresAt or expiresIn, not both");
169
+ }
170
+ if (expiresIn2 !== void 0) return expiryFromDuration(expiresIn2).toISOString();
171
+ if (expiresAt === void 0) return void 0;
172
+ const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
173
+ if (Number.isNaN(date.getTime())) {
174
+ throw new XTknConfigError(`expiresAt is not a valid date: ${String(expiresAt)}`);
175
+ }
176
+ return date.toISOString();
177
+ }
178
+
179
+ // ../js/src/client.ts
180
+ var DEFAULT_BASE_URL = "https://api.x-tkn.com";
181
+ var DEFAULT_TIMEOUT_MS = 3e4;
182
+ var API_KEY_ENV_VARS = ["X_TKN_API_KEY", "X_TKN_API_KEY_ID"];
183
+ function envApiKey() {
184
+ const env = typeof process !== "undefined" ? process.env : void 0;
185
+ if (!env) return void 0;
186
+ for (const name of API_KEY_ENV_VARS) {
187
+ const value = env[name];
188
+ if (value) return value;
189
+ }
190
+ return void 0;
191
+ }
192
+ function compact(input) {
193
+ const out = {};
194
+ for (const [key, value] of Object.entries(input)) {
195
+ if (value !== void 0) out[key] = value;
196
+ }
197
+ return out;
198
+ }
199
+ var XTkn = class _XTkn {
200
+ options;
201
+ baseUrl;
202
+ constructor(options = {}) {
203
+ this.options = options;
204
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
205
+ }
206
+ /**
207
+ * Mint a sandbox account and return a client already holding its key.
208
+ *
209
+ * No signup, no inbox, no key of your own — which is the point. This is the
210
+ * one call on the API with no credential, so it is the one way a coding
211
+ * agent gets from `npm install` to a working request without a human
212
+ * clicking a link in an email.
213
+ *
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.
217
+ *
218
+ * `account.apiKey` is returned once and cannot be read back. Persist it if
219
+ * the process may restart before the account is claimed.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * const { client, account } = await XTkn.createSandbox()
224
+ * console.log(`${account.limits.monthlyOperations} ops until ${account.expiresAt}`)
225
+ *
226
+ * const token = await client.createToken({ maxUses: 1, expiresIn: { minutes: 15 } })
227
+ * ```
228
+ */
229
+ static async createSandbox(options = {}) {
230
+ const bootstrap = new _XTkn(options);
231
+ const account = await bootstrap.request(
232
+ "POST",
233
+ "/accounts/sandbox",
234
+ void 0,
235
+ { auth: false }
236
+ );
237
+ return { client: new _XTkn({ ...options, apiKey: account.apiKey }), account };
238
+ }
239
+ /**
240
+ * Keep this sandbox account permanently.
241
+ *
242
+ * Takes a Firebase ID token for whoever is claiming it. The account this
243
+ * client authenticates as is the one claimed — there is no account id to
244
+ * pass and no way to name somebody else's.
245
+ *
246
+ * Existing tokens and keys survive. Throws {@link XTknRequestError} with a
247
+ * 409 when that email already owns an account; sign in with that one
248
+ * instead, since merging is not supported.
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * const { client } = await XTkn.createSandbox()
253
+ * // ... later, once a human has signed in ...
254
+ * await client.claim(await firebaseUser.getIdToken())
255
+ * ```
256
+ */
257
+ async claim(firebaseIdToken) {
258
+ if (!firebaseIdToken) {
259
+ throw new XTknConfigError("A Firebase ID token is required to claim an account");
260
+ }
261
+ return this.request("POST", "/accounts/claim", void 0, {
262
+ headers: { "X-Firebase-Token": firebaseIdToken }
263
+ });
264
+ }
265
+ /**
266
+ * Create a token.
267
+ *
268
+ * The returned {@link Token.code} is the only copy — the server stores a hash
269
+ * of it and cannot return it again.
270
+ *
271
+ * @throws {XTknConfigError} if both `expiresAt` and `expiresIn` are given.
272
+ * @throws {XTknRequestError} if a field fails server validation.
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * const token = await xtkn.createToken({
277
+ * type: 'password-reset',
278
+ * refId: user.id,
279
+ * maxUses: 1,
280
+ * expiresIn: { minutes: 30 },
281
+ * })
282
+ * await sendEmail(user.email, `https://app.example.com/reset#${token.code}`)
283
+ * ```
284
+ */
285
+ async createToken(input = {}) {
286
+ const { expiresAt, expiresIn: expiresIn2, idleTimeout, ...rest } = input;
287
+ const body = compact({
288
+ ...rest,
289
+ expiresAt: resolveExpiry(expiresAt, expiresIn2),
290
+ idleTimeoutSeconds: idleTimeout === void 0 ? void 0 : durationToSeconds(idleTimeout)
291
+ });
292
+ return this.request("POST", "/tokens/create", body);
293
+ }
294
+ /**
295
+ * Read a token without consuming a use.
296
+ *
297
+ * @throws {XTknNotFoundError} if no token with this code belongs to your account.
298
+ *
299
+ * @example
300
+ * ```ts
301
+ * const token = await xtkn.readToken(code)
302
+ * if (!token.isActive) return renderExpired()
303
+ * ```
304
+ *
305
+ * On a live sliding token — one created with
306
+ * {@link CreateTokenInput.idleTimeout} — a read counts as activity and moves
307
+ * its expiry forward like a redeem, without consuming a use.
308
+ */
309
+ async readToken(code2) {
310
+ return this.request("GET", `/tokens/${encodeURIComponent(this.requireCode(code2))}/read`);
311
+ }
312
+ /**
313
+ * Redeem a token, consuming one use.
314
+ *
315
+ * Scope the redeem wherever you can. `refId` confines the lookup to the
316
+ * identity the code was issued for, which is what bounds guessing; `type`
317
+ * stops a redemption consuming a different kind of code held by the same
318
+ * identity.
319
+ *
320
+ * @throws {XTknNotFoundError} if the code is wrong, or does not match the scoping.
321
+ * @throws {XTknGoneError} if the token is revoked, expired, or out of uses.
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * try {
326
+ * const token = await xtkn.redeemToken(code, { refId: user.id, type: 'password-reset' })
327
+ * await resetPassword(user, token.payload)
328
+ * } catch (err) {
329
+ * if (err instanceof XTknGoneError) return renderLinkAlreadyUsed()
330
+ * throw err
331
+ * }
332
+ * ```
333
+ */
334
+ async redeemToken(code2, options = {}) {
335
+ return this.request(
336
+ "PATCH",
337
+ `/tokens/${encodeURIComponent(this.requireCode(code2))}/redeem`,
338
+ compact({ refId: options.refId, type: options.type })
339
+ );
340
+ }
341
+ /**
342
+ * Change a token's mutable fields. Omitted fields are left alone.
343
+ *
344
+ * `description` is not updatable — the server's update handler ignores it.
345
+ *
346
+ * @throws {XTknConfigError} if both `expiresAt` and `expiresIn` are given.
347
+ * @throws {XTknNotFoundError} if no token with this code belongs to your account.
348
+ */
349
+ async updateToken(code2, updates = {}) {
350
+ const { expiresAt, expiresIn: expiresIn2, ...rest } = updates;
351
+ const body = compact({ ...rest, expiresAt: resolveExpiry(expiresAt, expiresIn2) });
352
+ return this.request(
353
+ "PATCH",
354
+ `/tokens/${encodeURIComponent(this.requireCode(code2))}/update`,
355
+ body
356
+ );
357
+ }
358
+ /**
359
+ * Push a token's expiry out to `duration` from now.
360
+ *
361
+ * Measured from the current time, not from the existing expiry, so calling
362
+ * this on an already-expired token revives it rather than extending from a
363
+ * date in the past.
364
+ *
365
+ * On a sliding token this moves the hard expiry,
366
+ * {@link Token.absoluteExpiresAt}, and restarts the idle window from now
367
+ * inside it. A sliding token does not need this to stay alive while in use.
368
+ *
369
+ * @example
370
+ * ```ts
371
+ * await xtkn.extendExpiration(sessionCode, { hours: 2 })
372
+ * ```
373
+ */
374
+ async extendExpiration(code2, duration) {
375
+ return this.updateToken(code2, { expiresAt: expiryFromDuration(duration) });
376
+ }
377
+ /**
378
+ * Revoke a token. Permanent, and takes effect immediately.
379
+ *
380
+ * @throws {XTknNotFoundError} if no token with this code belongs to your account.
381
+ */
382
+ async revokeToken(code2) {
383
+ return this.request(
384
+ "PATCH",
385
+ `/tokens/${encodeURIComponent(this.requireCode(code2))}/revoke`
386
+ );
387
+ }
388
+ /**
389
+ * Revoke every token matching a filter.
390
+ *
391
+ * One call revokes a bounded number of tokens. When
392
+ * {@link BulkRevokeResult.hasMore} comes back true there are more matches
393
+ * than were revoked — call again with the same filter until it is false.
394
+ *
395
+ * @throws {XTknConfigError} if neither `type` nor `refId` is set. An empty
396
+ * filter would mean "revoke everything", which is never worth allowing by
397
+ * omission.
398
+ *
399
+ * @example Log a user out everywhere
400
+ * ```ts
401
+ * let result
402
+ * do {
403
+ * result = await xtkn.revokeTokens({ refId: user.id, type: 'session' })
404
+ * } while (result.hasMore)
405
+ * ```
406
+ */
407
+ async revokeTokens(filter) {
408
+ if (!filter?.type && !filter?.refId) {
409
+ throw new XTknConfigError("revokeTokens requires at least one of type or refId");
410
+ }
411
+ return this.request(
412
+ "PATCH",
413
+ "/tokens/revoke",
414
+ compact({ type: filter.type, refId: filter.refId })
415
+ );
416
+ }
417
+ /**
418
+ * Permanently delete a token. Prefer {@link XTkn.revokeToken} unless you need
419
+ * the row gone — a revoked token still reports why a redemption failed,
420
+ * a deleted one is indistinguishable from one that never existed.
421
+ *
422
+ * @throws {XTknNotFoundError} if no token with this code belongs to your account.
423
+ */
424
+ async deleteToken(code2) {
425
+ await this.request("DELETE", `/tokens/${encodeURIComponent(this.requireCode(code2))}`);
426
+ }
427
+ /**
428
+ * List your tokens, newest first.
429
+ *
430
+ * Only `type`, `refId` and `isRevoked` are filterable; the server drops
431
+ * anything else in `where` without complaining, so a typo widens the result
432
+ * rather than erroring. `limit` is capped at 100.
433
+ *
434
+ * Codes are not exposed as a filter. The API does accept one, hashing the
435
+ * value to match `sha256(code)`, but it returns at most the single token you
436
+ * already hold the code for — {@link XTkn.readToken} does that directly.
437
+ *
438
+ * @example
439
+ * ```ts
440
+ * const { tokens, count } = await xtkn.listTokens({
441
+ * where: { refId: user.id, isRevoked: false },
442
+ * limit: 50,
443
+ * })
444
+ * ```
445
+ */
446
+ async listTokens(options = {}) {
447
+ const body = compact({
448
+ where: options.where,
449
+ sort: options.sort,
450
+ page: options.page,
451
+ limit: options.limit
452
+ });
453
+ return this.request("POST", "/tokens/list", body);
454
+ }
455
+ // ── internals ───────────────────────────────────────────────────────────
456
+ /**
457
+ * Read this key's plan, limits and quota position.
458
+ *
459
+ * Call it to find out what you may do *before* doing it. The alternative is
460
+ * discovering each fence by tripping it, which costs a failed request and —
461
+ * for anything with a side effect — leaves you guessing what did and did not
462
+ * happen.
463
+ *
464
+ * Cheap: it reaches nothing outside our own database.
465
+ *
466
+ * @example
467
+ * ```ts
468
+ * const me = await xtkn.account()
469
+ * if (!me.limits.recipientAuth) {
470
+ * // Do not even try to gate this secret; the create would 402.
471
+ * return sendUngated()
472
+ * }
473
+ * if (me.usage.remaining !== null && me.usage.remaining < batch.length) {
474
+ * return console.warn(`Only ${me.usage.remaining} operations left this month`)
475
+ * }
476
+ * ```
477
+ */
478
+ async account() {
479
+ return this.request("GET", "/account");
480
+ }
481
+ /**
482
+ * Get a Checkout link a human can pay through.
483
+ *
484
+ * **This does not upgrade anything.** It creates a link. The plan changes
485
+ * only once Stripe reports the completed subscription back to us — poll
486
+ * {@link account} if you need to know when.
487
+ *
488
+ * Paying is deliberately left to a person: recurring-subscription consent is
489
+ * the consent an agent has the weakest claim to give, and a card challenge is
490
+ * browser-shaped.
491
+ *
492
+ * Say what you want one of three ways — name a `tier`, name the `capability`
493
+ * you were refused, or pass nothing and get the next plan up.
494
+ *
495
+ * @example
496
+ * ```ts
497
+ * try {
498
+ * await xtkn.createWebhookEndpoint(url)
499
+ * } catch (err) {
500
+ * if (err instanceof XTknPaymentRequiredError) {
501
+ * const intent = await xtkn.upgradeIntent({ capability: err.capability as never })
502
+ * console.log(`Ask someone to pay here, within 30 minutes: ${intent.url}`)
503
+ * }
504
+ * }
505
+ * ```
506
+ */
507
+ async upgradeIntent(options = {}) {
508
+ return this.request("POST", "/billing/upgrade-intent", compact({ ...options }));
509
+ }
510
+ requireCode(code2) {
511
+ if (typeof code2 !== "string" || code2.length === 0) {
512
+ throw new XTknConfigError("A token code is required");
513
+ }
514
+ return code2;
515
+ }
516
+ resolveApiKey() {
517
+ const key = this.options.apiKey ?? envApiKey();
518
+ if (!key) {
519
+ throw new XTknConfigError(
520
+ `No API key. Pass { apiKey } to the XTkn constructor, or set ${API_KEY_ENV_VARS[0]}.`
521
+ );
522
+ }
523
+ return key;
524
+ }
525
+ /**
526
+ * Issue one request and unwrap the response.
527
+ *
528
+ * Two things v1 got wrong happen here. Non-2xx responses throw instead of
529
+ * being handed back typed as data, and the `{ success, data }` envelope is
530
+ * unwrapped so callers get the token rather than the wrapper around it.
531
+ */
532
+ async request(method, path, body, options = {}) {
533
+ const doFetch = this.options.fetch ?? globalThis.fetch;
534
+ if (typeof doFetch !== "function") {
535
+ throw new XTknConfigError(
536
+ "No fetch implementation. Node 18+ provides one; on older runtimes pass { fetch }."
537
+ );
538
+ }
539
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
540
+ const controller = timeoutMs > 0 ? new AbortController() : void 0;
541
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
542
+ let response;
543
+ let raw;
544
+ try {
545
+ response = await doFetch(`${this.baseUrl}${path}`, {
546
+ method,
547
+ headers: {
548
+ ...this.options.headers,
549
+ "Content-Type": "application/json",
550
+ // Resolved only when the endpoint needs it. `POST /accounts/sandbox`
551
+ // is the one route with no credential, and calling `resolveApiKey`
552
+ // there would throw the "no API key" error at the exact caller who
553
+ // is asking for one.
554
+ ...options.auth === false ? {} : { Authorization: this.resolveApiKey() },
555
+ ...options.headers
556
+ },
557
+ body: body === void 0 ? void 0 : JSON.stringify(body),
558
+ signal: controller?.signal
559
+ });
560
+ raw = response.status === 204 ? "" : await response.text();
561
+ } catch (err) {
562
+ if (err instanceof XTknError) throw err;
563
+ const aborted = err?.name === "AbortError";
564
+ throw new XTknConnectionError(
565
+ aborted ? `Request to ${path} timed out after ${timeoutMs}ms` : `Request to ${path} failed`,
566
+ err
567
+ );
568
+ } finally {
569
+ if (timer) clearTimeout(timer);
570
+ }
571
+ const parsed = raw ? safeJsonParse(raw) : void 0;
572
+ if (!response.ok) throw errorFromResponse(response.status, parsed, response.statusText);
573
+ return unwrap(parsed);
574
+ }
575
+ };
576
+ function safeJsonParse(raw) {
577
+ try {
578
+ return JSON.parse(raw);
579
+ } catch {
580
+ return raw;
581
+ }
582
+ }
583
+ function unwrap(parsed) {
584
+ if (parsed && typeof parsed === "object" && "success" in parsed && "data" in parsed) {
585
+ return parsed.data;
586
+ }
587
+ return parsed;
588
+ }
589
+
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 } });
603
+ }
604
+
605
+ // src/server.ts
606
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
607
+ import { z } from "zod";
608
+
609
+ // ../js/src/vendor/token-envelope.ts
610
+ var PREFIX_V1 = "xtkn.v1.";
611
+ var NONCE_BYTES = 12;
612
+ var TAG_BYTES = 16;
613
+ var KEY_BYTES = 32;
614
+ var B64URL = /^[A-Za-z0-9_-]*$/;
615
+ var B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
616
+ function encodeBase64Url(bytes) {
617
+ let binary = "";
618
+ const CHUNK = 32768;
619
+ for (let i = 0; i < bytes.length; i += CHUNK) {
620
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
621
+ }
622
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
623
+ }
624
+ function decodeBase64Url(value) {
625
+ if (!B64URL.test(value)) return null;
626
+ const tail = value.length % 4;
627
+ if (tail === 1) return null;
628
+ if (tail !== 0) {
629
+ const slack = tail === 2 ? 15 : 3;
630
+ const last = B64URL_ALPHABET.indexOf(value[value.length - 1]);
631
+ if (last < 0 || (last & slack) !== 0) return null;
632
+ }
633
+ const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
634
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
635
+ try {
636
+ const binary = atob(padded);
637
+ const out = new Uint8Array(binary.length);
638
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
639
+ return out;
640
+ } catch {
641
+ return null;
642
+ }
643
+ }
644
+ function buildEnvelope(nonce, ciphertext) {
645
+ if (nonce.length !== NONCE_BYTES) {
646
+ throw new Error(`nonce must be ${NONCE_BYTES} bytes, got ${nonce.length}`);
647
+ }
648
+ if (ciphertext.length <= TAG_BYTES) {
649
+ throw new Error(`ciphertext must exceed the ${TAG_BYTES}-byte tag`);
650
+ }
651
+ return `${PREFIX_V1}${encodeBase64Url(nonce)}.${encodeBase64Url(ciphertext)}`;
652
+ }
653
+
654
+ // ../js/src/vendor/token-crypto.ts
655
+ var ALGORITHM = "AES-GCM";
656
+ var TAG_BITS = TAG_BYTES * 8;
657
+ function subtle() {
658
+ const c = globalThis.crypto;
659
+ if (!c?.subtle) {
660
+ throw new Error("WebCrypto is unavailable \u2014 token encryption needs a secure context");
661
+ }
662
+ return c.subtle;
663
+ }
664
+ function generateKey() {
665
+ return globalThis.crypto.getRandomValues(new Uint8Array(KEY_BYTES));
666
+ }
667
+ function encodeKey(key) {
668
+ if (key.length !== KEY_BYTES) {
669
+ throw new Error(`key must be ${KEY_BYTES} bytes, got ${key.length}`);
670
+ }
671
+ return encodeBase64Url(key);
672
+ }
673
+ function decodeKey(value) {
674
+ const bytes = decodeBase64Url(value);
675
+ if (!bytes || bytes.length !== KEY_BYTES) return null;
676
+ return bytes;
677
+ }
678
+ async function importKey(key, usage) {
679
+ if (key.length !== KEY_BYTES) {
680
+ throw new Error(`key must be ${KEY_BYTES} bytes, got ${key.length}`);
681
+ }
682
+ return subtle().importKey("raw", key, ALGORITHM, false, [usage]);
683
+ }
684
+ async function encryptPayload(plaintext, key) {
685
+ const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
686
+ const cryptoKey = await importKey(key, "encrypt");
687
+ const ciphertext = await subtle().encrypt(
688
+ { name: ALGORITHM, iv: nonce, tagLength: TAG_BITS },
689
+ cryptoKey,
690
+ new TextEncoder().encode(plaintext)
691
+ );
692
+ return buildEnvelope(nonce, new Uint8Array(ciphertext));
693
+ }
694
+
695
+ // ../js/src/seal.ts
696
+ var DEFAULT_SECRET_LINK_ORIGIN = "https://x-tkn.com";
697
+ async function sealSecret(plaintext) {
698
+ if (typeof plaintext !== "string") {
699
+ throw new XTknConfigError("sealSecret takes a string; JSON.stringify anything else first");
700
+ }
701
+ if (!plaintext) {
702
+ throw new XTknConfigError("sealSecret needs a secret to seal; got an empty string");
703
+ }
704
+ if (!globalThis.crypto?.subtle) {
705
+ throw new XTknConfigError(
706
+ "sealSecret needs a global WebCrypto (globalThis.crypto): Node 20 or later, a browser, Deno, Bun or Workers"
707
+ );
708
+ }
709
+ const key = generateKey();
710
+ return { envelope: await encryptPayload(plaintext, key), key: encodeKey(key) };
711
+ }
712
+ function buildSecretLink(code2, key, options = {}) {
713
+ if (typeof code2 !== "string" || !code2) {
714
+ throw new XTknConfigError("A token code is required");
715
+ }
716
+ if (typeof key !== "string" || !decodeKey(key)) {
717
+ throw new XTknConfigError("key must be the key sealSecret returned: 43 characters of base64url");
718
+ }
719
+ const origin = (options.origin ?? DEFAULT_SECRET_LINK_ORIGIN).replace(/\/+$/, "");
720
+ return `${origin}/s/#${code2}.${key}`;
721
+ }
722
+
723
+ // src/tools.ts
724
+ var SECRET_TYPE = "secret";
725
+ 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.`;
728
+ async function createSecret(client, args) {
729
+ if (args.passphrase !== void 0 && args.recipientEmail !== void 0) {
730
+ throw new XTknConfigError("Set passphrase or recipientEmail, not both. A secret takes one recipient check.");
731
+ }
732
+ const { envelope, key } = await sealSecret(args.secret);
733
+ const input = {
734
+ type: SECRET_TYPE,
735
+ payload: envelope,
736
+ maxUses: 1,
737
+ expiresIn: args.expiresIn,
738
+ description: args.note
739
+ };
740
+ if (args.passphrase !== void 0) {
741
+ input.authMethod = "passphrase";
742
+ input.authPassphrase = args.passphrase;
743
+ } else if (args.recipientEmail !== void 0) {
744
+ input.authMethod = "email";
745
+ input.authEmail = args.recipientEmail;
746
+ }
747
+ const token = await client.createToken(input);
748
+ if (!token.code) throw new XTknError("X-TKN created the secret but returned no code for it", 0);
749
+ return {
750
+ link: buildSecretLink(token.code, key),
751
+ code: token.code,
752
+ expiresAt: token.expiresAt ?? null,
753
+ recipientAuth: input.authMethod ?? null
754
+ };
755
+ }
756
+ async function checkStatus(client, code2) {
757
+ return statusOf(await client.readToken(code2));
758
+ }
759
+ async function revokeSecret(client, code2) {
760
+ return statusOf(await client.revokeToken(code2));
761
+ }
762
+ function statusOf(token) {
763
+ const uses = token.uses ?? 0;
764
+ const opened = token.isUsed ?? (token.maxUses != null && uses >= token.maxUses);
765
+ const status2 = opened ? "opened" : token.isRevoked ? "revoked" : token.isExpired ? "expired" : "waiting";
766
+ return {
767
+ status: status2,
768
+ type: token.type ?? null,
769
+ openedAt: uses > 0 ? token.lastUsedAt ?? null : null,
770
+ expiresAt: token.expiresAt ?? null,
771
+ createdAt: token.createdAt ?? null
772
+ };
773
+ }
774
+ function describeError(err) {
775
+ if (err instanceof XTknPaymentRequiredError) {
776
+ if (err.upgradeMethod === "claim") return SANDBOX_KEY_MESSAGE;
777
+ const plan = err.requiredPlan ? `the ${err.requiredPlan} plan` : "a paid plan";
778
+ const where = err.upgradeUrl ? ` A person can upgrade here: ${err.upgradeUrl}` : "";
779
+ return `${err.message} This needs ${plan}.${where}`;
780
+ }
781
+ if (err instanceof XTknRequestError && err.message.startsWith("Sandbox accounts cannot create secrets")) {
782
+ return SANDBOX_KEY_MESSAGE;
783
+ }
784
+ if (err instanceof XTknConfigError && err.message.startsWith("No API key")) return NO_KEY_MESSAGE;
785
+ if (err instanceof XTknAuthError && err.status === 401) {
786
+ return `X-TKN refused the API key in ${API_KEY_ENV_VARS[0]} (${err.message}). It may be mistyped or revoked.`;
787
+ }
788
+ if (err instanceof XTknNotFoundError) {
789
+ return "No secret with that code on this account. Use the code create_secret returned.";
790
+ }
791
+ if (err instanceof XTknRateLimitError) return `X-TKN is refusing more requests for now: ${err.message}`;
792
+ if (err instanceof XTknConnectionError) return `Could not reach X-TKN: ${err.message}`;
793
+ if (err instanceof XTknError) return err.status ? `X-TKN answered ${err.status}: ${err.message}` : err.message;
794
+ return "The tool failed unexpectedly.";
795
+ }
796
+
797
+ // src/server.ts
798
+ var INSTRUCTIONS = [
799
+ "X-TKN hands a secret to a person as a one-time link.",
800
+ "create_secret encrypts the secret on this machine. X-TKN stores only ciphertext, and the key travels in the link after the #.",
801
+ "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."
803
+ ].join(" ");
804
+ var durationField = z.number().int().min(0);
805
+ var expiresIn = z.object({
806
+ days: durationField.optional(),
807
+ hours: durationField.optional(),
808
+ minutes: durationField.optional()
809
+ }).refine((d) => (d.days ?? 0) + (d.hours ?? 0) + (d.minutes ?? 0) > 0, {
810
+ message: "expiresIn needs at least one field above zero"
811
+ });
812
+ var code = z.string().min(1).describe("The code create_secret returned for this secret.");
813
+ var statusShape = {
814
+ status: z.enum(["waiting", "opened", "expired", "revoked"]).describe("waiting: not opened yet. opened: the recipient has it, and the link no longer works."),
815
+ type: z.string().nullable(),
816
+ openedAt: z.string().nullable(),
817
+ expiresAt: z.string().nullable(),
818
+ createdAt: z.string().nullable()
819
+ };
820
+ function createXtknMcpServer({ client }) {
821
+ const server = new McpServer({ name: "x-tkn", title: "X-TKN", version: VERSION }, { instructions: INSTRUCTIONS });
822
+ server.registerTool(
823
+ "create_secret",
824
+ {
825
+ title: "Create a one-time secret link",
826
+ 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
+ inputSchema: {
828
+ 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."),
830
+ passphrase: z.string().min(8).max(256).optional().describe(
831
+ "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
+ ),
833
+ recipientEmail: z.email().optional().describe(
834
+ "Make the recipient enter a 6-digit code emailed to this address before the secret opens. Team plan and above. Set this or passphrase, not both."
835
+ ),
836
+ note: z.string().max(4e3).optional().describe(
837
+ "A note for your own records, shown in the X-TKN console and never to the recipient. Stored unencrypted, so never put the secret in it."
838
+ )
839
+ },
840
+ outputSchema: {
841
+ link: z.string().describe("The one-time link for the recipient."),
842
+ code: z.string().describe("Pass this to check_status or revoke_secret."),
843
+ expiresAt: z.string().nullable(),
844
+ recipientAuth: z.enum(["passphrase", "email"]).nullable()
845
+ },
846
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
847
+ },
848
+ (args) => run(async () => created(await createSecret(client, args)))
849
+ );
850
+ server.registerTool(
851
+ "check_status",
852
+ {
853
+ 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.",
855
+ inputSchema: { code },
856
+ outputSchema: statusShape,
857
+ annotations: { readOnlyHint: true, openWorldHint: true }
858
+ },
859
+ ({ code: code2 }) => run(async () => status(await checkStatus(client, code2)))
860
+ );
861
+ server.registerTool(
862
+ "revoke_secret",
863
+ {
864
+ title: "Revoke a secret link",
865
+ description: "Cancel a secret link so it can no longer be opened. Permanent, and immediate. A secret that was already opened stays opened: revoking cannot take it back from the recipient.",
866
+ inputSchema: { code },
867
+ outputSchema: statusShape,
868
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
869
+ },
870
+ ({ code: code2 }) => run(async () => status(await revokeSecret(client, code2)))
871
+ );
872
+ return server;
873
+ }
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
+ }
880
+ }
881
+ function created(secret) {
882
+ const lines = [
883
+ `One-time link: ${secret.link}`,
884
+ `It opens once, then stops working. Expires: ${secret.expiresAt ?? "on the plan default"}.`
885
+ ];
886
+ if (secret.recipientAuth === "passphrase") {
887
+ lines.push("The recipient will be asked for the passphrase. Send it by a different channel than the link.");
888
+ } else if (secret.recipientAuth === "email") {
889
+ lines.push("The recipient will be emailed a 6-digit code to enter before it opens.");
890
+ }
891
+ lines.push(`Code for check_status and revoke_secret: ${secret.code}`);
892
+ return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: { ...secret } };
893
+ }
894
+ var STATUS_TEXT = {
895
+ waiting: "Waiting: not opened yet.",
896
+ opened: "Opened: the recipient has it, and the link no longer works.",
897
+ expired: "Expired before anyone opened it.",
898
+ revoked: "Revoked before anyone opened it."
899
+ };
900
+ function status(secret) {
901
+ const lines = [STATUS_TEXT[secret.status]];
902
+ if (secret.openedAt) lines.push(`Opened at ${secret.openedAt}.`);
903
+ if (secret.status === "waiting" && secret.expiresAt) lines.push(`Expires at ${secret.expiresAt}.`);
904
+ return { content: [{ type: "text", text: lines.join(" ") }], structuredContent: { ...secret } };
905
+ }
906
+
907
+ // src/index.ts
908
+ async function main(argv) {
909
+ if (argv.includes("--version")) {
910
+ process.stdout.write(`${VERSION}
911
+ `);
912
+ return;
913
+ }
914
+ if (!apiKeyFrom(process.env)) console.error(NO_KEY_MESSAGE);
915
+ const server = createXtknMcpServer({ client: clientFrom(process.env) });
916
+ await server.connect(new StdioServerTransport());
917
+ }
918
+ main(process.argv.slice(2)).catch((err) => {
919
+ console.error("x-tkn-mcp failed to start:", err instanceof Error ? err.message : err);
920
+ process.exit(1);
921
+ });
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@fennecstudio/x-tkn-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "mcpName": "io.github.fennecstudio/x-tkn-mcp",
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
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "x-tkn",
10
+ "secret",
11
+ "one-time",
12
+ "agent"
13
+ ],
14
+ "homepage": "https://x-tkn.com",
15
+ "license": "ISC",
16
+ "author": "Fennec Studio",
17
+ "type": "module",
18
+ "bin": {
19
+ "x-tkn-mcp": "dist/index.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "vendor": "node ../js/scripts/vendor-crypto.mjs",
29
+ "build": "npm run vendor && node scripts/build.mjs",
30
+ "typecheck": "npm run vendor && tsc -p tsconfig.json --noEmit",
31
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build",
32
+ "deploy": "npm publish",
33
+ "test": "npm run vendor && vitest run"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "1.30.1",
40
+ "zod": "^4.5.4"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.5.4",
44
+ "esbuild": "^0.25.12",
45
+ "typescript": "^5.7.3",
46
+ "vitest": "^3.2.4"
47
+ },
48
+ "packageManager": "pnpm@10.34.5"
49
+ }