@yawlabs/vend-mcp 0.1.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 +204 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.js +292 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @yawlabs/vend-mcp
|
|
2
|
+
|
|
3
|
+
Payments for MCP servers. Add license key gating and usage metering in two lines of code.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install @yawlabs/vend-mcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { vendAuth } from '@yawlabs/vend-mcp';
|
|
13
|
+
|
|
14
|
+
const guard = vendAuth({
|
|
15
|
+
apiKey: process.env.VEND_API_KEY!,
|
|
16
|
+
gates: {
|
|
17
|
+
search: 'free',
|
|
18
|
+
execute: 'pro',
|
|
19
|
+
deploy: 'business',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// In your tool handler:
|
|
24
|
+
if (!(await guard.canAccess('execute'))) {
|
|
25
|
+
return { content: [{ type: 'text', text: guard.upgradeMessage('execute') }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
guard.recordUsage('execute');
|
|
29
|
+
// ... run the tool
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## How It Works
|
|
33
|
+
|
|
34
|
+
1. Customers purchase a license key at your project's checkout page on [vend.sh](https://vend.sh)
|
|
35
|
+
2. They set `VEND_LICENSE_KEY` in their MCP client config
|
|
36
|
+
3. Your server uses `vendAuth` to gate tools by tier and track usage
|
|
37
|
+
|
|
38
|
+
The SDK validates the key against the Vend API, caches the result, and lets you gate any tool to any tier.
|
|
39
|
+
|
|
40
|
+
## API Reference
|
|
41
|
+
|
|
42
|
+
### `vendAuth(options): VendGuard`
|
|
43
|
+
|
|
44
|
+
Creates a guard instance. Validation is lazy — no API calls until the first `canAccess()` or `validate()`.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const guard = vendAuth({
|
|
48
|
+
// Required
|
|
49
|
+
apiKey: 'vend_xxx', // Your project API key from the dashboard
|
|
50
|
+
gates: { search: 'free', run: 'pro' }, // Tool name → minimum tier
|
|
51
|
+
|
|
52
|
+
// Optional
|
|
53
|
+
licenseKey: 'VEND-XXXX-...', // Override (default: process.env.VEND_LICENSE_KEY)
|
|
54
|
+
cacheTtl: 300, // Seconds to cache successful validations (default: 300)
|
|
55
|
+
errorCacheTtl: 30, // Seconds to cache failed validations (default: 30)
|
|
56
|
+
|
|
57
|
+
// Observability hooks (all optional)
|
|
58
|
+
onValidate: (result, fromCache) => {},
|
|
59
|
+
onActivate: (result) => {},
|
|
60
|
+
onUsage: (toolName, success) => {},
|
|
61
|
+
onError: (operation, error) => {},
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `guard.canAccess(toolName): Promise<boolean>`
|
|
66
|
+
|
|
67
|
+
Check if the current key can access a tool. Calls `validate()` internally (cached).
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
if (!(await guard.canAccess('deploy'))) {
|
|
71
|
+
return { content: [{ type: 'text', text: guard.upgradeMessage('deploy') }] };
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `guard.validate(): Promise<ValidationResult>`
|
|
76
|
+
|
|
77
|
+
Validate the license key against the Vend API. Results are cached per the TTL settings.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const result = await guard.validate();
|
|
81
|
+
// {
|
|
82
|
+
// valid: true,
|
|
83
|
+
// tier: 'pro',
|
|
84
|
+
// tierName: 'Pro',
|
|
85
|
+
// toolGates: { search: true, execute: true },
|
|
86
|
+
// requestLimit: 10000,
|
|
87
|
+
// activationsRemaining: 4,
|
|
88
|
+
// }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
On failure, `reason` tells you why:
|
|
92
|
+
|
|
93
|
+
| Reason | Meaning |
|
|
94
|
+
|--------|---------|
|
|
95
|
+
| `no_license_key` | No key provided |
|
|
96
|
+
| `invalid_key_format` | Key doesn't match `VEND-XXXX-XXXX-XXXX-XXXX` |
|
|
97
|
+
| `invalid_api_key` | Your project API key is wrong |
|
|
98
|
+
| `key_not_found` | Key doesn't exist or doesn't belong to your project |
|
|
99
|
+
| `key_inactive` / `key_expired` / `key_revoked` | Key is no longer valid |
|
|
100
|
+
| `network_error` | Couldn't reach vend.sh (falls back to cache if available) |
|
|
101
|
+
| `validation_failed` | Other API error |
|
|
102
|
+
|
|
103
|
+
### `guard.recordUsage(toolName): Promise<boolean>`
|
|
104
|
+
|
|
105
|
+
Record a tool invocation. Returns `true` if recorded, `false` on failure. Safe to fire-and-forget:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// Fire-and-forget (won't block your handler)
|
|
109
|
+
guard.recordUsage('search');
|
|
110
|
+
|
|
111
|
+
// Or await for confirmation
|
|
112
|
+
const ok = await guard.recordUsage('search');
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `guard.activate(instanceName, instanceId): Promise<ActivationResult>`
|
|
116
|
+
|
|
117
|
+
Activate the key for a specific MCP client instance. Call this once on startup.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const result = await guard.activate('claude-desktop', crypto.randomUUID());
|
|
121
|
+
if (!result.activated) {
|
|
122
|
+
console.error(`Activation failed: ${result.reason}`);
|
|
123
|
+
// reason: 'activation_limit_reached' | 'key_not_found' | 'key_inactive' | ...
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `guard.tier: TierGate`
|
|
128
|
+
|
|
129
|
+
The current customer's tier (after validation). One of: `'free'`, `'starter'`, `'pro'`, `'business'`, `'enterprise'`.
|
|
130
|
+
|
|
131
|
+
### `guard.upgradeMessage(toolName): string`
|
|
132
|
+
|
|
133
|
+
A human-readable message explaining why a tool is gated and how to upgrade.
|
|
134
|
+
|
|
135
|
+
### `guard.licenseKey = 'VEND-...'`
|
|
136
|
+
|
|
137
|
+
Update the license key at runtime. Clears the cache.
|
|
138
|
+
|
|
139
|
+
### `createToolGuard(guard)`
|
|
140
|
+
|
|
141
|
+
Auto-gate and track usage for an MCP `CallToolRequest` handler:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { vendAuth, createToolGuard } from '@yawlabs/vend-mcp';
|
|
145
|
+
|
|
146
|
+
const guard = vendAuth({ apiKey: '...', gates: { search: 'free', run: 'pro' } });
|
|
147
|
+
const gated = createToolGuard(guard);
|
|
148
|
+
|
|
149
|
+
server.setRequestHandler(
|
|
150
|
+
CallToolRequestSchema,
|
|
151
|
+
gated(async (request) => {
|
|
152
|
+
// This only runs if the key has access to request.params.name
|
|
153
|
+
// Usage is recorded automatically on success
|
|
154
|
+
return { content: [{ type: 'text', text: 'result' }] };
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Tiers
|
|
160
|
+
|
|
161
|
+
Tiers are ordered. A `pro` key can access `free` and `starter` tools.
|
|
162
|
+
|
|
163
|
+
| Tier | Level |
|
|
164
|
+
|------|-------|
|
|
165
|
+
| `free` | 0 |
|
|
166
|
+
| `starter` | 1 |
|
|
167
|
+
| `pro` | 2 |
|
|
168
|
+
| `business` | 3 |
|
|
169
|
+
| `enterprise` | 4 |
|
|
170
|
+
|
|
171
|
+
## Observability
|
|
172
|
+
|
|
173
|
+
Hook into every SDK operation for logging, metrics, or alerting:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const guard = vendAuth({
|
|
177
|
+
apiKey: process.env.VEND_API_KEY!,
|
|
178
|
+
gates: { search: 'free', execute: 'pro' },
|
|
179
|
+
onValidate: (result, fromCache) => {
|
|
180
|
+
console.log(`[vend] validate: valid=${result.valid} tier=${result.tier} cache=${fromCache}`);
|
|
181
|
+
},
|
|
182
|
+
onActivate: (result) => {
|
|
183
|
+
if (!result.activated) console.warn(`[vend] activation failed: ${result.reason}`);
|
|
184
|
+
},
|
|
185
|
+
onUsage: (toolName, success) => {
|
|
186
|
+
metrics.increment('vend.usage', { tool: toolName, ok: String(success) });
|
|
187
|
+
},
|
|
188
|
+
onError: (operation, error) => {
|
|
189
|
+
console.error(`[vend] ${operation} error:`, error);
|
|
190
|
+
sentry.captureException(error);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Environment Variables
|
|
196
|
+
|
|
197
|
+
| Variable | Description |
|
|
198
|
+
|----------|-------------|
|
|
199
|
+
| `VEND_LICENSE_KEY` | Customer's license key (set by the end user) |
|
|
200
|
+
| `VEND_API_URL` | Override the API URL (default: `https://vend.sh`) |
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export type TierGate = 'free' | 'starter' | 'pro' | 'business' | 'enterprise';
|
|
2
|
+
export type ValidationReason = 'no_license_key' | 'invalid_key_format' | 'invalid_api_key' | 'key_not_found' | 'key_inactive' | 'key_expired' | 'key_revoked' | 'validation_failed' | 'network_error';
|
|
3
|
+
export type ActivationReason = 'no_license_key' | 'invalid_key_format' | 'invalid_api_key' | 'key_not_found' | 'key_inactive' | 'activation_limit_reached' | 'activation_failed' | 'network_error';
|
|
4
|
+
export interface VendAuthOptions {
|
|
5
|
+
/** Your project's API key from the Vend dashboard */
|
|
6
|
+
apiKey: string;
|
|
7
|
+
/** The customer's license key (usually from env: VEND_LICENSE_KEY) */
|
|
8
|
+
licenseKey?: string;
|
|
9
|
+
/** Map of tool names to minimum required tier */
|
|
10
|
+
gates: Record<string, TierGate>;
|
|
11
|
+
/** Cache TTL in seconds for successful validations (default: 300 = 5 min) */
|
|
12
|
+
cacheTtl?: number;
|
|
13
|
+
/** Cache TTL in seconds for failed validations (default: 30) */
|
|
14
|
+
errorCacheTtl?: number;
|
|
15
|
+
/** Called after every validation attempt (cached or not) */
|
|
16
|
+
onValidate?: (result: ValidationResult, fromCache: boolean) => void;
|
|
17
|
+
/** Called after every activation attempt */
|
|
18
|
+
onActivate?: (result: ActivationResult) => void;
|
|
19
|
+
/** Called after every usage recording attempt */
|
|
20
|
+
onUsage?: (toolName: string, success: boolean) => void;
|
|
21
|
+
/** Called on any network or API error */
|
|
22
|
+
onError?: (operation: 'validate' | 'activate' | 'usage', error: unknown) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface ValidationResult {
|
|
25
|
+
valid: boolean;
|
|
26
|
+
tier?: TierGate;
|
|
27
|
+
tierName?: string;
|
|
28
|
+
toolGates?: Record<string, boolean> | null;
|
|
29
|
+
requestLimit?: number | null;
|
|
30
|
+
activationsRemaining?: number | null;
|
|
31
|
+
reason?: ValidationReason;
|
|
32
|
+
}
|
|
33
|
+
export interface ActivationResult {
|
|
34
|
+
activated: boolean;
|
|
35
|
+
reason?: ActivationReason;
|
|
36
|
+
}
|
|
37
|
+
export declare class VendGuard {
|
|
38
|
+
private apiKey;
|
|
39
|
+
private _licenseKey;
|
|
40
|
+
private gates;
|
|
41
|
+
private successCacheTtl;
|
|
42
|
+
private errorCacheTtl;
|
|
43
|
+
private cached;
|
|
44
|
+
private cachedAt;
|
|
45
|
+
private hooks;
|
|
46
|
+
constructor(opts: VendAuthOptions);
|
|
47
|
+
/** Update the license key (clears cache) */
|
|
48
|
+
set licenseKey(key: string);
|
|
49
|
+
/** Validate the license key against the Vend API (cached) */
|
|
50
|
+
validate(): Promise<ValidationResult>;
|
|
51
|
+
/** Check if a tool is accessible at the current tier */
|
|
52
|
+
canAccess(toolName: string): Promise<boolean>;
|
|
53
|
+
/** Get the current customer's tier (after validate() has been called) */
|
|
54
|
+
get tier(): TierGate;
|
|
55
|
+
/** Get the required tier for a tool */
|
|
56
|
+
requiredTier(toolName: string): TierGate;
|
|
57
|
+
/** Get a human-readable upgrade message for a denied tool */
|
|
58
|
+
upgradeMessage(toolName: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Record a tool invocation.
|
|
61
|
+
* Returns true if the usage was recorded, false on failure.
|
|
62
|
+
* Safe to fire-and-forget (errors are swallowed) or await for confirmation.
|
|
63
|
+
*/
|
|
64
|
+
recordUsage(toolName: string): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Activate the license key for this MCP client instance.
|
|
67
|
+
* Returns an object with `activated` boolean and an optional `reason` on failure.
|
|
68
|
+
*/
|
|
69
|
+
activate(instanceName: string, instanceId: string): Promise<ActivationResult>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create a Vend guard for your MCP server.
|
|
73
|
+
* Validates lazily on first canAccess() call, not at construction time.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* import { vendAuth } from '@vend/mcp';
|
|
78
|
+
*
|
|
79
|
+
* const guard = vendAuth({
|
|
80
|
+
* apiKey: process.env.VEND_API_KEY!,
|
|
81
|
+
* gates: { 'search': 'free', 'execute': 'pro' },
|
|
82
|
+
* onValidate: (result, fromCache) => {
|
|
83
|
+
* console.log(`[vend] validate: ${result.valid} (cache: ${fromCache})`);
|
|
84
|
+
* },
|
|
85
|
+
* onError: (op, err) => {
|
|
86
|
+
* console.error(`[vend] ${op} error:`, err);
|
|
87
|
+
* },
|
|
88
|
+
* });
|
|
89
|
+
*
|
|
90
|
+
* // In your tool handler:
|
|
91
|
+
* if (!await guard.canAccess('execute')) {
|
|
92
|
+
* return { content: [{ type: 'text', text: guard.upgradeMessage('execute') }] };
|
|
93
|
+
* }
|
|
94
|
+
* guard.recordUsage('execute');
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export declare function vendAuth(opts: VendAuthOptions): VendGuard;
|
|
98
|
+
/**
|
|
99
|
+
* Create a tool handler wrapper that auto-gates and tracks usage.
|
|
100
|
+
* Usage is recorded only when the handler completes without throwing.
|
|
101
|
+
* If the handler throws, the error propagates and no usage is recorded.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { vendAuth, createToolGuard } from '@vend/mcp';
|
|
106
|
+
*
|
|
107
|
+
* const guard = vendAuth({ apiKey: '...', gates: { 'search': 'free', 'execute': 'pro' } });
|
|
108
|
+
* const gated = createToolGuard(guard);
|
|
109
|
+
*
|
|
110
|
+
* server.setRequestHandler(CallToolRequestSchema, gated(async (request) => {
|
|
111
|
+
* return { content: [{ type: 'text', text: 'result' }] };
|
|
112
|
+
* }));
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export declare function createToolGuard(guard: VendGuard): <T extends {
|
|
116
|
+
params: {
|
|
117
|
+
name: string;
|
|
118
|
+
};
|
|
119
|
+
}>(handler: (request: T) => Promise<any>) => (request: T) => Promise<any>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
const VEND_API_URL = process.env.VEND_API_URL || 'https://vend.sh';
|
|
2
|
+
// Key format: VEND-XXXX-XXXX-XXXX-XXXX (chars: A-Z excluding I,O + 2-9)
|
|
3
|
+
const KEY_PATTERN = /^VEND-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/;
|
|
4
|
+
// --- Tier ordering ---
|
|
5
|
+
const TIER_ORDER = {
|
|
6
|
+
free: 0,
|
|
7
|
+
starter: 1,
|
|
8
|
+
pro: 2,
|
|
9
|
+
business: 3,
|
|
10
|
+
enterprise: 4,
|
|
11
|
+
};
|
|
12
|
+
function tierMeetsMinimum(actual, required) {
|
|
13
|
+
return TIER_ORDER[actual] >= TIER_ORDER[required];
|
|
14
|
+
}
|
|
15
|
+
// --- Status → reason mapping ---
|
|
16
|
+
function reasonFromStatus(status, body) {
|
|
17
|
+
if (status === 401)
|
|
18
|
+
return 'invalid_api_key';
|
|
19
|
+
if (status === 404)
|
|
20
|
+
return 'key_not_found';
|
|
21
|
+
if (body?.error?.startsWith('key_'))
|
|
22
|
+
return body.error;
|
|
23
|
+
return 'validation_failed';
|
|
24
|
+
}
|
|
25
|
+
function activationReasonFromStatus(status, body) {
|
|
26
|
+
if (status === 401)
|
|
27
|
+
return 'invalid_api_key';
|
|
28
|
+
if (status === 404)
|
|
29
|
+
return 'key_not_found';
|
|
30
|
+
if (body?.error === 'key_inactive')
|
|
31
|
+
return 'key_inactive';
|
|
32
|
+
if (body?.error === 'activation_limit_reached')
|
|
33
|
+
return 'activation_limit_reached';
|
|
34
|
+
return 'activation_failed';
|
|
35
|
+
}
|
|
36
|
+
// --- VendGuard ---
|
|
37
|
+
export class VendGuard {
|
|
38
|
+
apiKey;
|
|
39
|
+
_licenseKey;
|
|
40
|
+
gates;
|
|
41
|
+
successCacheTtl;
|
|
42
|
+
errorCacheTtl;
|
|
43
|
+
cached = null;
|
|
44
|
+
cachedAt = 0;
|
|
45
|
+
hooks;
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
this.apiKey = opts.apiKey;
|
|
48
|
+
this._licenseKey = opts.licenseKey || process.env.VEND_LICENSE_KEY || '';
|
|
49
|
+
this.gates = opts.gates;
|
|
50
|
+
this.successCacheTtl = (opts.cacheTtl ?? 300) * 1000;
|
|
51
|
+
this.errorCacheTtl = (opts.errorCacheTtl ?? 30) * 1000;
|
|
52
|
+
this.hooks = {
|
|
53
|
+
onValidate: opts.onValidate,
|
|
54
|
+
onActivate: opts.onActivate,
|
|
55
|
+
onUsage: opts.onUsage,
|
|
56
|
+
onError: opts.onError,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Update the license key (clears cache) */
|
|
60
|
+
set licenseKey(key) {
|
|
61
|
+
this._licenseKey = key;
|
|
62
|
+
this.cached = null;
|
|
63
|
+
this.cachedAt = 0;
|
|
64
|
+
}
|
|
65
|
+
/** Validate the license key against the Vend API (cached) */
|
|
66
|
+
async validate() {
|
|
67
|
+
const ttl = this.cached?.valid ? this.successCacheTtl : this.errorCacheTtl;
|
|
68
|
+
if (this.cached && Date.now() - this.cachedAt < ttl) {
|
|
69
|
+
this.hooks.onValidate?.(this.cached, true);
|
|
70
|
+
return this.cached;
|
|
71
|
+
}
|
|
72
|
+
if (!this._licenseKey) {
|
|
73
|
+
this.cached = { valid: false, tier: 'free', reason: 'no_license_key' };
|
|
74
|
+
this.cachedAt = Date.now();
|
|
75
|
+
this.hooks.onValidate?.(this.cached, false);
|
|
76
|
+
return this.cached;
|
|
77
|
+
}
|
|
78
|
+
if (!KEY_PATTERN.test(this._licenseKey)) {
|
|
79
|
+
this.cached = { valid: false, tier: 'free', reason: 'invalid_key_format' };
|
|
80
|
+
this.cachedAt = Date.now();
|
|
81
|
+
this.hooks.onValidate?.(this.cached, false);
|
|
82
|
+
return this.cached;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${VEND_API_URL}/api/v1/keys/validate`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'Content-Type': 'application/json',
|
|
89
|
+
'x-vend-api-key': this.apiKey,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({ key: this._licenseKey }),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const body = await res.json().catch(() => ({}));
|
|
95
|
+
this.cached = {
|
|
96
|
+
valid: false,
|
|
97
|
+
tier: 'free',
|
|
98
|
+
reason: reasonFromStatus(res.status, body),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
this.cached = (await res.json());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
this.hooks.onError?.('validate', err);
|
|
107
|
+
// On network error, use previous successful cache if available
|
|
108
|
+
if (this.cached?.valid) {
|
|
109
|
+
this.hooks.onValidate?.(this.cached, true);
|
|
110
|
+
return this.cached;
|
|
111
|
+
}
|
|
112
|
+
this.cached = { valid: false, tier: 'free', reason: 'network_error' };
|
|
113
|
+
}
|
|
114
|
+
this.cachedAt = Date.now();
|
|
115
|
+
this.hooks.onValidate?.(this.cached, false);
|
|
116
|
+
return this.cached;
|
|
117
|
+
}
|
|
118
|
+
/** Check if a tool is accessible at the current tier */
|
|
119
|
+
async canAccess(toolName) {
|
|
120
|
+
const result = await this.validate();
|
|
121
|
+
const requiredTier = this.gates[toolName];
|
|
122
|
+
// If no gate defined, allow by default
|
|
123
|
+
if (!requiredTier)
|
|
124
|
+
return true;
|
|
125
|
+
// If key is invalid, only allow free-tier tools
|
|
126
|
+
if (!result.valid)
|
|
127
|
+
return requiredTier === 'free';
|
|
128
|
+
return tierMeetsMinimum(result.tier, requiredTier);
|
|
129
|
+
}
|
|
130
|
+
/** Get the current customer's tier (after validate() has been called) */
|
|
131
|
+
get tier() {
|
|
132
|
+
return this.cached?.tier ?? 'free';
|
|
133
|
+
}
|
|
134
|
+
/** Get the required tier for a tool */
|
|
135
|
+
requiredTier(toolName) {
|
|
136
|
+
return this.gates[toolName] ?? 'free';
|
|
137
|
+
}
|
|
138
|
+
/** Get a human-readable upgrade message for a denied tool */
|
|
139
|
+
upgradeMessage(toolName) {
|
|
140
|
+
const required = this.gates[toolName];
|
|
141
|
+
if (!required)
|
|
142
|
+
return 'This tool requires a valid license key.';
|
|
143
|
+
return `This tool requires the ${required} tier. Upgrade your license at your project's checkout page.`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Record a tool invocation.
|
|
147
|
+
* Returns true if the usage was recorded, false on failure.
|
|
148
|
+
* Safe to fire-and-forget (errors are swallowed) or await for confirmation.
|
|
149
|
+
*/
|
|
150
|
+
recordUsage(toolName) {
|
|
151
|
+
if (!this._licenseKey)
|
|
152
|
+
return Promise.resolve(false);
|
|
153
|
+
return fetch(`${VEND_API_URL}/api/v1/usage`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: {
|
|
156
|
+
'Content-Type': 'application/json',
|
|
157
|
+
'x-vend-api-key': this.apiKey,
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
license_key: this._licenseKey,
|
|
161
|
+
tool_name: toolName,
|
|
162
|
+
}),
|
|
163
|
+
})
|
|
164
|
+
.then((res) => {
|
|
165
|
+
const ok = res.ok;
|
|
166
|
+
this.hooks.onUsage?.(toolName, ok);
|
|
167
|
+
return ok;
|
|
168
|
+
})
|
|
169
|
+
.catch((err) => {
|
|
170
|
+
this.hooks.onError?.('usage', err);
|
|
171
|
+
this.hooks.onUsage?.(toolName, false);
|
|
172
|
+
return false;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Activate the license key for this MCP client instance.
|
|
177
|
+
* Returns an object with `activated` boolean and an optional `reason` on failure.
|
|
178
|
+
*/
|
|
179
|
+
async activate(instanceName, instanceId) {
|
|
180
|
+
if (!this._licenseKey) {
|
|
181
|
+
const result = { activated: false, reason: 'no_license_key' };
|
|
182
|
+
this.hooks.onActivate?.(result);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
if (!KEY_PATTERN.test(this._licenseKey)) {
|
|
186
|
+
const result = { activated: false, reason: 'invalid_key_format' };
|
|
187
|
+
this.hooks.onActivate?.(result);
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`${VEND_API_URL}/api/v1/keys/activate`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: {
|
|
194
|
+
'Content-Type': 'application/json',
|
|
195
|
+
'x-vend-api-key': this.apiKey,
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
key: this._licenseKey,
|
|
199
|
+
instance_name: instanceName,
|
|
200
|
+
instance_id: instanceId,
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const body = await res.json().catch(() => ({}));
|
|
205
|
+
const result = {
|
|
206
|
+
activated: false,
|
|
207
|
+
reason: activationReasonFromStatus(res.status, body),
|
|
208
|
+
};
|
|
209
|
+
this.hooks.onActivate?.(result);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
const result = { activated: true };
|
|
213
|
+
this.hooks.onActivate?.(result);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
this.hooks.onError?.('activate', err);
|
|
218
|
+
const result = { activated: false, reason: 'network_error' };
|
|
219
|
+
this.hooks.onActivate?.(result);
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// --- Main export ---
|
|
225
|
+
/**
|
|
226
|
+
* Create a Vend guard for your MCP server.
|
|
227
|
+
* Validates lazily on first canAccess() call, not at construction time.
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* import { vendAuth } from '@vend/mcp';
|
|
232
|
+
*
|
|
233
|
+
* const guard = vendAuth({
|
|
234
|
+
* apiKey: process.env.VEND_API_KEY!,
|
|
235
|
+
* gates: { 'search': 'free', 'execute': 'pro' },
|
|
236
|
+
* onValidate: (result, fromCache) => {
|
|
237
|
+
* console.log(`[vend] validate: ${result.valid} (cache: ${fromCache})`);
|
|
238
|
+
* },
|
|
239
|
+
* onError: (op, err) => {
|
|
240
|
+
* console.error(`[vend] ${op} error:`, err);
|
|
241
|
+
* },
|
|
242
|
+
* });
|
|
243
|
+
*
|
|
244
|
+
* // In your tool handler:
|
|
245
|
+
* if (!await guard.canAccess('execute')) {
|
|
246
|
+
* return { content: [{ type: 'text', text: guard.upgradeMessage('execute') }] };
|
|
247
|
+
* }
|
|
248
|
+
* guard.recordUsage('execute');
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
export function vendAuth(opts) {
|
|
252
|
+
return new VendGuard(opts);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Create a tool handler wrapper that auto-gates and tracks usage.
|
|
256
|
+
* Usage is recorded only when the handler completes without throwing.
|
|
257
|
+
* If the handler throws, the error propagates and no usage is recorded.
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```ts
|
|
261
|
+
* import { vendAuth, createToolGuard } from '@vend/mcp';
|
|
262
|
+
*
|
|
263
|
+
* const guard = vendAuth({ apiKey: '...', gates: { 'search': 'free', 'execute': 'pro' } });
|
|
264
|
+
* const gated = createToolGuard(guard);
|
|
265
|
+
*
|
|
266
|
+
* server.setRequestHandler(CallToolRequestSchema, gated(async (request) => {
|
|
267
|
+
* return { content: [{ type: 'text', text: 'result' }] };
|
|
268
|
+
* }));
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
export function createToolGuard(guard) {
|
|
272
|
+
return function gated(handler) {
|
|
273
|
+
return async (request) => {
|
|
274
|
+
const toolName = request.params.name;
|
|
275
|
+
const allowed = await guard.canAccess(toolName);
|
|
276
|
+
if (!allowed) {
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: 'text',
|
|
281
|
+
text: guard.upgradeMessage(toolName),
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// Execute handler first, then record usage on success
|
|
287
|
+
const result = await handler(request);
|
|
288
|
+
guard.recordUsage(toolName);
|
|
289
|
+
return result;
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yawlabs/vend-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Vend SDK for MCP servers — license key gating and usage metering in two lines",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["mcp", "payments", "license-keys", "monetization", "vend"],
|
|
24
|
+
"author": "Yaw Labs <contact@yaw.sh>",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://vend.sh",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+ssh://git@github.com/YawLabs/vend.git",
|
|
30
|
+
"directory": "packages/mcp"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.8.0"
|
|
34
|
+
}
|
|
35
|
+
}
|