@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.
- package/README.md +129 -2
- package/dist/index.js +921 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
1
|
+
# X-TKN MCP server
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|