@fdkey/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ export type Policy = {
2
+ type: 'once_per_session';
3
+ } | {
4
+ type: 'each_call';
5
+ } | {
6
+ type: 'every_minutes';
7
+ minutes: number;
8
+ };
9
+ export type PolicyShorthand = 'once_per_session' | 'each_call';
10
+ export interface ProtectEntry {
11
+ policy: Policy | PolicyShorthand;
12
+ }
13
+ export interface FdkeyConfig {
14
+ /** Integrator's VPS API key (Bearer token). Required. */
15
+ apiKey: string;
16
+ /** Tools that require verification, keyed by tool name. */
17
+ protect?: Record<string, ProtectEntry>;
18
+ /** 'easy' | 'medium' | 'hard' — passed to VPS on challenge fetch. Default: 'medium' */
19
+ difficulty?: 'easy' | 'medium' | 'hard';
20
+ /** What happens when the agent fails the puzzle. Default: 'block' */
21
+ onFail?: 'block' | 'allow';
22
+ /** What happens when the FDKEY VPS is unreachable. Default: 'allow'
23
+ * (fail-open) — the protected tool runs as if no verification were
24
+ * required, so an FDKEY outage doesn't brick integrator workflows.
25
+ * Set to 'block' if your threat model prefers fail-closed. */
26
+ onVpsError?: 'block' | 'allow';
27
+ /** When true, blocked-tool errors embed the puzzle data so the agent can submit without a separate fdkey_get_challenge call. Default: false */
28
+ inlineChallenge?: boolean;
29
+ /** Skip discovery and use this VPS URL directly (local dev, self-hosted). */
30
+ vpsUrl?: string;
31
+ /** Override the Cloudflare CDN discovery URL. */
32
+ discoveryUrl?: string;
33
+ /** Arbitrary string-keyed dimensions forwarded to FDKEY on every
34
+ * challenge request, stored as `tags` on the session row. Useful for
35
+ * multi-tenant setups (`tenant_id`), env labels (`env: prod`), A/B
36
+ * experiments, deployment markers, etc.
37
+ *
38
+ * IMPORTANT: tags travel to FDKEY's servers and may end up in our
39
+ * analytics database. **Never put end-user PII in here.** Bounded
40
+ * server-side at 16 keys, 50 chars/key, 200 chars/value — extra
41
+ * fields are rejected with HTTP 400. */
42
+ tags?: Record<string, string>;
43
+ }
44
+ export interface SessionState {
45
+ /** True after any successful submit on this connection. Never reset to false. */
46
+ verified: boolean;
47
+ /** Timestamp of the most recent successful verification (puzzle solve). */
48
+ verifiedAt: number | null;
49
+ /** Timestamp (ms epoch) of the last access. Drives the LRU + TTL
50
+ * eviction in the per-server session map so the SDK doesn't leak
51
+ * session entries on long-lived shared servers. Touched by every
52
+ * call into `getSession(...)`. */
53
+ lastTouchedAt: number;
54
+ /** True after a successful submit; consumed (set false) by the next each_call tool call. */
55
+ freshVerificationAvailable: boolean;
56
+ /** Internal: the active VPS challenge ID for this connection. Agent never sees this. */
57
+ pendingChallengeId: string | null;
58
+ /** Decoded JWT payload from the most recent successful verification. Surfaced to
59
+ * integrator tool handlers via getFdkeyContext(). The raw JWT itself is never stored. */
60
+ lastClaims: Record<string, unknown> | null;
61
+ /** AI client identification captured from MCP `initialize` handshake.
62
+ * Lazy-copied from the closure-scope `latestClientInfo` on the first tool call
63
+ * for this session (so it reflects whichever client most recently completed
64
+ * initialize on this server instance). Forwarded to the VPS on /v1/challenge. */
65
+ clientInfo: {
66
+ name: string;
67
+ version: string;
68
+ title?: string;
69
+ capabilities?: Record<string, unknown>;
70
+ } | null;
71
+ /** Negotiated MCP protocol version captured from the initialize request
72
+ * params (e.g. "2025-03-26"). Filled by the InitializeRequestSchema
73
+ * interceptor in withFdkey(). */
74
+ protocolVersion: string | null;
75
+ /** The MCP-Session-Id header value from HTTP Streamable transport, or the
76
+ * literal `'stdio'` for stdio transport. Captured from `extra.sessionId` on
77
+ * the first tool call. Forwarded to the VPS on /v1/challenge. */
78
+ mcpSessionId: string | null;
79
+ /** Inferred MCP transport flavor: stdio if no sessionId on the handler extra,
80
+ * http otherwise, unknown if we haven't seen a tool call yet. */
81
+ transport: 'stdio' | 'http' | 'unknown';
82
+ }
83
+ /** Agent block forwarded to the VPS in the /v1/challenge request body.
84
+ * Mirrors the fields stored in `vps_sessions.agent_info.agent`. */
85
+ export interface AgentMeta {
86
+ client_name?: string;
87
+ client_version?: string;
88
+ client_title?: string;
89
+ client_capabilities?: Record<string, unknown>;
90
+ protocol_version?: string;
91
+ mcp_session_id?: string;
92
+ transport?: 'stdio' | 'http' | 'unknown';
93
+ }
94
+ /** Integrator block forwarded to the VPS — facts about the MCP server +
95
+ * SDK version that's calling us. Stored in `vps_sessions.agent_info.integrator`
96
+ * alongside the VPS-observed `ip` + `user_agent`. */
97
+ export interface IntegratorMeta {
98
+ server_name?: string;
99
+ server_version?: string;
100
+ sdk_version?: string;
101
+ }
102
+ /** Combined per-challenge metadata bundle. Computed by withFdkey() each time
103
+ * a challenge is fetched and passed to VpsClient.fetchChallenge() as a single
104
+ * object so the wire format can grow without churning the call signature. */
105
+ export interface ChallengeMeta {
106
+ agent?: AgentMeta;
107
+ integrator?: IntegratorMeta;
108
+ tags?: Record<string, string>;
109
+ }
110
+ /** Returned by `IVpsRouter.getTarget()`. Encapsulates everything a caller
111
+ * needs to make a TLS request that lands on the correct VPS in the fleet:
112
+ * a stable URL, an optional dispatcher pinned to a chosen IP (Node-only;
113
+ * undefined on Workers/Bun/Deno or when StaticRouter is used), and the IP
114
+ * itself for failure tracking.
115
+ *
116
+ * `dispatcher` is typed `unknown` so this module — and any module that
117
+ * imports it — never touches the undici types. The runtime check happens
118
+ * inside vps-client.ts which casts it back when passing to fetch. */
119
+ export interface RoutingTarget {
120
+ url: string;
121
+ dispatcher?: unknown;
122
+ ip?: string;
123
+ }
124
+ export interface IVpsRouter {
125
+ getTarget(): Promise<RoutingTarget>;
126
+ recordFailure(ip: string | undefined): void;
127
+ }
128
+ /** A VPS in the fleet, as listed in cdn.fdkey.com/endpoints.json.
129
+ * All FDKEY VPSs serve HTTPS for the same hostname (`api.fdkey.com`); the
130
+ * SDK pins each connection to a specific IP and presents `api.fdkey.com`
131
+ * as the SNI value, so the cert validates regardless of which IP we pick.
132
+ * This is the standard SDK-driven multi-region routing pattern (MongoDB
133
+ * driver, AWS SDK, etc.) and means adding a VPS = adding an entry here,
134
+ * with zero DNS work. */
135
+ export interface VpsEndpoint {
136
+ /** Public IPv4 of the VPS. */
137
+ ip: string;
138
+ /** Region tag for analytics + admin-key env-var derivation. */
139
+ region: string;
140
+ /** Selection weight (currently informational; sort uses error+latency). */
141
+ weight: number;
142
+ /** Marker for graceful decommissioning. SDK ignores deprecated entries. */
143
+ deprecated?: boolean;
144
+ }
145
+ export interface WellKnownKey {
146
+ alg: string;
147
+ kid: string;
148
+ public_key_pem: string;
149
+ }
150
+ export interface WellKnownPayload {
151
+ issuer: string;
152
+ keys: WellKnownKey[];
153
+ jwt_default_lifetime_seconds: number;
154
+ }
155
+ export declare function normalisePolicy(p: Policy | PolicyShorthand): Policy;
156
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ export function normalisePolicy(p) {
2
+ if (typeof p === 'string')
3
+ return { type: p };
4
+ return p;
5
+ }
6
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAmKA,MAAM,UAAU,eAAe,CAAC,CAA2B;IACzD,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;IAC9C,OAAO,CAAC,CAAC;AACX,CAAC"}
@@ -0,0 +1,42 @@
1
+ import type { ChallengeMeta, IVpsRouter } from './types.js';
2
+ export interface ChallengeResponse {
3
+ challenge_id: string;
4
+ expires_at: string;
5
+ expires_in_seconds?: number;
6
+ difficulty: string;
7
+ types_served: string[];
8
+ header?: string;
9
+ puzzles: Record<string, unknown>;
10
+ footer?: string;
11
+ }
12
+ export interface SubmitResponse {
13
+ verified: boolean;
14
+ jwt?: string;
15
+ types_passed?: number;
16
+ types_served?: number;
17
+ required_to_pass?: number;
18
+ breakdown?: Record<string, unknown>;
19
+ }
20
+ export declare class VpsHttpError extends Error {
21
+ readonly status: number;
22
+ readonly body: {
23
+ error?: string;
24
+ message?: string;
25
+ [k: string]: unknown;
26
+ };
27
+ constructor(status: number, body: {
28
+ error?: string;
29
+ message?: string;
30
+ [k: string]: unknown;
31
+ });
32
+ }
33
+ export declare class VpsClient {
34
+ private readonly router;
35
+ private readonly apiKey;
36
+ private readonly difficulty;
37
+ constructor(router: IVpsRouter, apiKey: string, difficulty: string);
38
+ fetchChallenge(meta?: ChallengeMeta): Promise<ChallengeResponse>;
39
+ submitAnswers(challengeId: string, answers: Record<string, unknown>): Promise<SubmitResponse>;
40
+ private post;
41
+ }
42
+ //# sourceMappingURL=vps-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vps-client.d.ts","sourceRoot":"","sources":["../src/vps-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,OAAO,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC;AAMD,qBAAa,YAAa,SAAQ,KAAK;aAEnB,MAAM,EAAE,MAAM;aACd,IAAI,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE;gBADhE,MAAM,EAAE,MAAM,EACd,IAAI,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE;CAInF;AAED,qBAAa,SAAS;IAElB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAFV,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM;IAG/B,cAAc,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAehE,aAAa,CACjB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,cAAc,CAAC;YAOZ,IAAI;CA0CnB"}
@@ -0,0 +1,90 @@
1
+ function hasValue(obj) {
2
+ return Object.values(obj).some((v) => v !== undefined && v !== null);
3
+ }
4
+ export class VpsHttpError extends Error {
5
+ status;
6
+ body;
7
+ constructor(status, body) {
8
+ super(body.error ?? `HTTP ${status}`);
9
+ this.status = status;
10
+ this.body = body;
11
+ }
12
+ }
13
+ export class VpsClient {
14
+ router;
15
+ apiKey;
16
+ difficulty;
17
+ constructor(router, apiKey, difficulty) {
18
+ this.router = router;
19
+ this.apiKey = apiKey;
20
+ this.difficulty = difficulty;
21
+ }
22
+ async fetchChallenge(meta) {
23
+ const target = await this.router.getTarget();
24
+ const body = {
25
+ difficulty: this.difficulty,
26
+ client_type: 'mcp',
27
+ };
28
+ // Only include each block if at least one field inside is populated —
29
+ // keeps the wire payload clean when the caller has no metadata yet
30
+ // (e.g. challenge fetched before any tool call has fired oninitialized).
31
+ if (meta?.agent && hasValue(meta.agent))
32
+ body.agent = meta.agent;
33
+ if (meta?.integrator && hasValue(meta.integrator))
34
+ body.integrator = meta.integrator;
35
+ if (meta?.tags && Object.keys(meta.tags).length > 0)
36
+ body.tags = meta.tags;
37
+ return this.post(target, '/v1/challenge', body);
38
+ }
39
+ async submitAnswers(challengeId, answers) {
40
+ const target = await this.router.getTarget();
41
+ return this.post(target, '/v1/submit', {
42
+ challenge_id: challengeId, answers,
43
+ });
44
+ }
45
+ async post(target, path, body) {
46
+ const fullUrl = `${target.url}${path}`;
47
+ // Build init separately so we only attach `dispatcher` when present.
48
+ // Workers/Bun/Deno don't have undici Agent, so dispatcher will be
49
+ // undefined and the global fetch is invoked with a clean RequestInit.
50
+ const init = {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/json',
54
+ Authorization: `Bearer ${this.apiKey}`,
55
+ },
56
+ body: JSON.stringify(body),
57
+ signal: AbortSignal.timeout(10000),
58
+ };
59
+ if (target.dispatcher)
60
+ init.dispatcher = target.dispatcher;
61
+ let res;
62
+ try {
63
+ res = await fetch(fullUrl, init);
64
+ }
65
+ catch (err) {
66
+ // Network failure / timeout — surface as failure to caller, mark endpoint
67
+ this.router.recordFailure(target.ip);
68
+ throw err;
69
+ }
70
+ const text = await res.text();
71
+ let parsed = {};
72
+ if (text) {
73
+ try {
74
+ parsed = JSON.parse(text);
75
+ }
76
+ catch {
77
+ parsed = { _raw: text };
78
+ }
79
+ }
80
+ if (!res.ok) {
81
+ // 4xx = client/state error from VPS — do NOT mark endpoint as failed
82
+ // 5xx = server error — mark endpoint as failed for failover
83
+ if (res.status >= 500)
84
+ this.router.recordFailure(target.ip);
85
+ throw new VpsHttpError(res.status, parsed);
86
+ }
87
+ return parsed;
88
+ }
89
+ }
90
+ //# sourceMappingURL=vps-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vps-client.js","sourceRoot":"","sources":["../src/vps-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import type { IVpsRouter, RoutingTarget } from './types.js';
2
+ /** Multi-VPS discovery + IP-pinning router. Fetch endpoint list from cdn.fdkey.com, parallel-probe
3
+ * each IP via HEAD https://api.fdkey.com/health pinned to that IP, sort
4
+ * by (error count ASC, latency ASC), pick winner. Re-probe every hour or
5
+ * on `recordFailure(ip)`. */
6
+ export declare class VpsRouter implements IVpsRouter {
7
+ private readonly discoveryUrl;
8
+ private endpointCache;
9
+ private selectedIp;
10
+ private dispatchers;
11
+ private latencies;
12
+ private errorCounts;
13
+ private nextProbe;
14
+ constructor(discoveryUrl?: string);
15
+ getTarget(): Promise<RoutingTarget>;
16
+ recordFailure(ip: string | undefined): void;
17
+ /** Build (and cache) an undici Agent that pins all connections to `ip`
18
+ * while leaving SNI and cert validation to use the URL's hostname.
19
+ * Dispatchers are reused across calls — creating a new one per request
20
+ * would defeat the connection-pooling benefits. */
21
+ private dispatcherFor;
22
+ private refreshEndpoints;
23
+ private fetchEndpoints;
24
+ /** HEAD https://api.fdkey.com/health pinned per-IP. We use a per-call
25
+ * ad-hoc dispatcher (not the cached one) so a probe failure doesn't
26
+ * leave a soured connection in the pool. */
27
+ private probeAll;
28
+ }
29
+ //# sourceMappingURL=vps-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vps-router.d.ts","sourceRoot":"","sources":["../src/vps-router.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAe,UAAU,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAqCzE;;;8BAG8B;AAC9B,qBAAa,SAAU,YAAW,UAAU;IAC1C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,aAAa,CAAgC;IACrD,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,WAAW,CAA4B;IAC/C,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,SAAS,CAAK;gBAEV,YAAY,CAAC,EAAE,MAAM;IAI3B,SAAS,IAAI,OAAO,CAAC,aAAa,CAAC;IAYzC,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAQ3C;;;wDAGoD;IACpD,OAAO,CAAC,aAAa;YAuBP,gBAAgB;YAsBhB,cAAc;IAmB5B;;iDAE6C;YAC/B,QAAQ;CAgCvB"}
@@ -0,0 +1,146 @@
1
+ import { Agent, fetch } from 'undici';
2
+ const DEFAULT_DISCOVERY_URL = 'https://cdn.fdkey.com/endpoints.json';
3
+ /** All FDKEY VPSs serve TLS for this hostname. The SDK uses it as the SNI
4
+ * value when connecting to any IP from the discovery list — every box in
5
+ * the fleet holds a Let's Encrypt cert for this name (acquired via the
6
+ * DNS-01 challenge so multiple boxes can share the cert without
7
+ * fighting over HTTP-01). */
8
+ const FDKEY_API_HOSTNAME = 'api.fdkey.com';
9
+ const PROBE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
10
+ const DISCOVERY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
11
+ /** Multi-VPS discovery + IP-pinning router. Fetch endpoint list from cdn.fdkey.com, parallel-probe
12
+ * each IP via HEAD https://api.fdkey.com/health pinned to that IP, sort
13
+ * by (error count ASC, latency ASC), pick winner. Re-probe every hour or
14
+ * on `recordFailure(ip)`. */
15
+ export class VpsRouter {
16
+ discoveryUrl;
17
+ endpointCache = null;
18
+ selectedIp = null;
19
+ dispatchers = new Map();
20
+ latencies = new Map();
21
+ errorCounts = new Map();
22
+ nextProbe = 0;
23
+ constructor(discoveryUrl) {
24
+ this.discoveryUrl = discoveryUrl ?? DEFAULT_DISCOVERY_URL;
25
+ }
26
+ async getTarget() {
27
+ if (!this.selectedIp || Date.now() >= this.nextProbe) {
28
+ await this.refreshEndpoints();
29
+ }
30
+ const ip = this.selectedIp;
31
+ return {
32
+ url: `https://${FDKEY_API_HOSTNAME}`,
33
+ dispatcher: this.dispatcherFor(ip),
34
+ ip,
35
+ };
36
+ }
37
+ recordFailure(ip) {
38
+ if (!ip)
39
+ return;
40
+ this.errorCounts.set(ip, (this.errorCounts.get(ip) ?? 0) + 1);
41
+ if (this.selectedIp === ip) {
42
+ this.selectedIp = null; // force re-selection next call
43
+ }
44
+ }
45
+ /** Build (and cache) an undici Agent that pins all connections to `ip`
46
+ * while leaving SNI and cert validation to use the URL's hostname.
47
+ * Dispatchers are reused across calls — creating a new one per request
48
+ * would defeat the connection-pooling benefits. */
49
+ dispatcherFor(ip) {
50
+ let d = this.dispatchers.get(ip);
51
+ if (d)
52
+ return d;
53
+ const lookup = (_host, opts, cb) => {
54
+ if (opts.all) {
55
+ cb(null, [{ address: ip, family: 4 }]);
56
+ }
57
+ else {
58
+ cb(null, ip, 4);
59
+ }
60
+ };
61
+ d = new Agent({
62
+ connect: {
63
+ // Hand undici a custom resolver: regardless of what hostname is
64
+ // being requested, return this IP. The TLS handshake still uses
65
+ // the URL's hostname for SNI + cert verification, which is what
66
+ // makes the IP-pin trick work.
67
+ lookup,
68
+ },
69
+ });
70
+ this.dispatchers.set(ip, d);
71
+ return d;
72
+ }
73
+ async refreshEndpoints() {
74
+ const endpoints = await this.fetchEndpoints();
75
+ const active = endpoints.filter((e) => !e.deprecated);
76
+ if (active.length === 0) {
77
+ throw new Error('fdkey: no active VPS endpoints found in discovery list');
78
+ }
79
+ const results = await this.probeAll(active);
80
+ // Sort by (error_count ASC, latency ASC). Endpoints that didn't respond
81
+ // to the probe get latency = Infinity and rank last among equal error
82
+ // counts — but they're still candidates if everything else is dead.
83
+ results.sort((a, b) => {
84
+ const ea = this.errorCounts.get(a.ip) ?? 0;
85
+ const eb = this.errorCounts.get(b.ip) ?? 0;
86
+ if (ea !== eb)
87
+ return ea - eb;
88
+ return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity);
89
+ });
90
+ this.selectedIp = results[0].ip;
91
+ this.nextProbe = Date.now() + PROBE_INTERVAL_MS;
92
+ }
93
+ async fetchEndpoints() {
94
+ if (this.endpointCache &&
95
+ Date.now() - this.endpointCache.fetchedAt < DISCOVERY_CACHE_TTL_MS) {
96
+ return this.endpointCache.endpoints;
97
+ }
98
+ try {
99
+ const res = await fetch(this.discoveryUrl, { signal: AbortSignal.timeout(5000) });
100
+ if (!res.ok)
101
+ throw new Error(`discovery fetch ${res.status}`);
102
+ const data = (await res.json());
103
+ this.endpointCache = { endpoints: data, fetchedAt: Date.now() };
104
+ return data;
105
+ }
106
+ catch (err) {
107
+ if (this.endpointCache)
108
+ return this.endpointCache.endpoints; // stale ok
109
+ throw new Error(`fdkey: cannot reach discovery URL and no cached endpoints: ${err}`);
110
+ }
111
+ }
112
+ /** HEAD https://api.fdkey.com/health pinned per-IP. We use a per-call
113
+ * ad-hoc dispatcher (not the cached one) so a probe failure doesn't
114
+ * leave a soured connection in the pool. */
115
+ async probeAll(endpoints) {
116
+ return Promise.all(endpoints.map(async (e) => {
117
+ const start = Date.now();
118
+ try {
119
+ const probeLookup = (_h, opts, cb) => {
120
+ if (opts.all) {
121
+ cb(null, [{ address: e.ip, family: 4 }]);
122
+ }
123
+ else {
124
+ cb(null, e.ip, 4);
125
+ }
126
+ };
127
+ const probeAgent = new Agent({
128
+ connect: { lookup: probeLookup },
129
+ });
130
+ await fetch(`https://${FDKEY_API_HOSTNAME}/health`, {
131
+ method: 'HEAD',
132
+ signal: AbortSignal.timeout(3000),
133
+ dispatcher: probeAgent,
134
+ });
135
+ await probeAgent.close();
136
+ const latencyMs = Date.now() - start;
137
+ this.latencies.set(e.ip, latencyMs);
138
+ return { ip: e.ip, latencyMs };
139
+ }
140
+ catch {
141
+ return { ip: e.ip, latencyMs: null };
142
+ }
143
+ }));
144
+ }
145
+ }
146
+ //# sourceMappingURL=vps-router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vps-router.js","sourceRoot":"","sources":["../src/vps-router.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import { type KeyLike } from 'jose';
2
+ import type { IVpsRouter } from './types.js';
3
+ /** Fetches and caches the public-key list at `${vpsBase}/.well-known/fdkey.json`.
4
+ * Goes through the VpsRouter so the request lands on the same IP currently
5
+ * serving production traffic (and uses the same dispatcher / SNI / cert
6
+ * handling — just like challenge/submit calls). */
7
+ export declare class WellKnownClient {
8
+ private readonly router;
9
+ private cache;
10
+ constructor(router: IVpsRouter);
11
+ getKey(kid: string): Promise<KeyLike | null>;
12
+ private refresh;
13
+ }
14
+ //# sourceMappingURL=well-known.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"well-known.d.ts","sourceRoot":"","sources":["../src/well-known.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,OAAO,EAAE,MAAM,MAAM,CAAC;AAChD,OAAO,KAAK,EAAoB,UAAU,EAAE,MAAM,YAAY,CAAC;AAS/D;;;oDAGoD;AACpD,qBAAa,eAAe;IAGd,OAAO,CAAC,QAAQ,CAAC,MAAM;IAFnC,OAAO,CAAC,KAAK,CAAyB;gBAET,MAAM,EAAE,UAAU;IAEzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;YAUpC,OAAO;CAmBtB"}
@@ -0,0 +1,42 @@
1
+ import { importSPKI } from 'jose';
2
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
3
+ /** Fetches and caches the public-key list at `${vpsBase}/.well-known/fdkey.json`.
4
+ * Goes through the VpsRouter so the request lands on the same IP currently
5
+ * serving production traffic (and uses the same dispatcher / SNI / cert
6
+ * handling — just like challenge/submit calls). */
7
+ export class WellKnownClient {
8
+ router;
9
+ cache = null;
10
+ constructor(router) {
11
+ this.router = router;
12
+ }
13
+ async getKey(kid) {
14
+ if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
15
+ const k = this.cache.keys.get(kid);
16
+ if (k)
17
+ return k;
18
+ // kid not in cache — may have just rotated, refetch once
19
+ }
20
+ await this.refresh();
21
+ return this.cache.keys.get(kid) ?? null;
22
+ }
23
+ async refresh() {
24
+ const target = await this.router.getTarget();
25
+ const init = {
26
+ signal: AbortSignal.timeout(5000),
27
+ };
28
+ if (target.dispatcher)
29
+ init.dispatcher = target.dispatcher;
30
+ const res = await fetch(`${target.url}/.well-known/fdkey.json`, init);
31
+ if (!res.ok)
32
+ throw new Error(`fdkey: well-known fetch failed ${res.status}`);
33
+ const payload = (await res.json());
34
+ const keys = new Map();
35
+ for (const k of payload.keys) {
36
+ const key = await importSPKI(k.public_key_pem, k.alg);
37
+ keys.set(k.kid, key);
38
+ }
39
+ this.cache = { keys, fetchedAt: Date.now() };
40
+ }
41
+ }
42
+ //# sourceMappingURL=well-known.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"well-known.js","sourceRoot":"","sources":["../src/well-known.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAgB,MAAM,MAAM,CAAC;AAGhD,MAAM,YAAY,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,SAAS;AAO9C;;;oDAGoD;AACpD,MAAM,OAAO,eAAe;IAGG;IAFrB,KAAK,GAAoB,IAAI,CAAC;IAEtC,YAA6B,MAAkB;QAAlB,WAAM,GAAN,MAAM,CAAY;IAAG,CAAC;IAEnD,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,YAAY,EAAE,CAAC;YACnE,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YACnC,IAAI,CAAC;gBAAE,OAAO,CAAC,CAAC;YAChB,yDAAyD;QAC3D,CAAC;QACD,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QACrB,OAAO,IAAI,CAAC,KAAM,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;IAC3C,CAAC;IAEO,KAAK,CAAC,OAAO;QACnB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,CAAC;QAC7C,MAAM,IAAI,GAA2C;YACnD,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC;SAClC,CAAC;QACF,IAAI,MAAM,CAAC,UAAU;YAAE,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;QAC3D,MAAM,GAAG,GAAG,MAAM,KAAK,CACrB,GAAG,MAAM,CAAC,GAAG,yBAAyB,EACtC,IAAmB,CACpB,CAAC;QACF,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,kCAAkC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7E,MAAM,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAqB,CAAC;QACvD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAmB,CAAC;QACxC,KAAK,MAAM,CAAC,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YAC7B,MAAM,GAAG,GAAG,MAAM,UAAU,CAAC,CAAC,CAAC,cAAc,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC;YACtD,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACvB,CAAC;QACD,IAAI,CAAC,KAAK,GAAG,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;IAC/C,CAAC;CACF"}
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@fdkey/mcp",
3
+ "version": "0.2.0",
4
+ "description": "FDKEY verification middleware for MCP servers — gate AI-agent access behind LLM-only puzzles. Runs on Node 18+, Cloudflare Workers, Bun, and Deno.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsc --watch",
17
+ "test": "vitest run",
18
+ "prepublishOnly": "tsc"
19
+ },
20
+ "peerDependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.29.0"
22
+ },
23
+ "dependencies": {
24
+ "jose": "^5.10.0",
25
+ "zod": "^3.24.0"
26
+ },
27
+ "optionalDependencies": {
28
+ "undici": "^8.2.0"
29
+ },
30
+ "devDependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "@types/node": "^25.6.0",
33
+ "typescript": "^5.8.0",
34
+ "undici": "^8.2.0",
35
+ "vitest": "^2.1.0"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "CHANGELOG.md",
41
+ "LICENSE"
42
+ ],
43
+ "keywords": [
44
+ "mcp",
45
+ "model-context-protocol",
46
+ "fdkey",
47
+ "captcha",
48
+ "verification",
49
+ "ai-agent",
50
+ "anti-bot",
51
+ "middleware",
52
+ "cloudflare-workers",
53
+ "edge"
54
+ ],
55
+ "author": "FDKEY",
56
+ "license": "MIT",
57
+ "homepage": "https://fdkey.com",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/fdkey/sdks.git",
61
+ "directory": "typescript"
62
+ },
63
+ "bugs": {
64
+ "url": "https://github.com/fdkey/sdks/issues"
65
+ },
66
+ "engines": {
67
+ "node": ">=18.17"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public"
71
+ }
72
+ }