@getly/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 +110 -0
- package/dist/api.d.ts +72 -0
- package/dist/api.js +109 -0
- package/dist/categories.d.ts +19 -0
- package/dist/categories.js +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +29 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.js +133 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +33 -0
- package/dist/tools.d.ts +28 -0
- package/dist/tools.js +667 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# @getly/mcp
|
|
2
|
+
|
|
3
|
+
MCP server that lets Claude, Cursor, Windsurf and any other [Model Context Protocol](https://modelcontextprotocol.io) client run your [Getly](https://www.getly.store) digital-products store: create and publish products, upload files, write blog posts, mint coupons and instant checkout links, inspect licenses and sales — all from chat.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @getly/mcp init # guided setup for Claude Code / Cursor / Claude Desktop / Windsurf
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
1. A Getly account with a store (free): https://www.getly.store/sell
|
|
12
|
+
2. An API key: https://www.getly.store/dashboard/developer/keys — grant **only the scopes you need** (see [Security](#security)).
|
|
13
|
+
|
|
14
|
+
The key is read from the `GETLY_API_KEY` environment variable — the server never accepts keys as tool arguments and never prints them.
|
|
15
|
+
|
|
16
|
+
## Install & configure
|
|
17
|
+
|
|
18
|
+
### Claude Code
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
claude mcp add getly --env GETLY_API_KEY=YOUR_GETLY_API_KEY -- npx -y @getly/mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Cursor (`~/.cursor/mcp.json`)
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"getly": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": ["-y", "@getly/mcp"],
|
|
32
|
+
"env": { "GETLY_API_KEY": "YOUR_GETLY_API_KEY" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Claude Desktop
|
|
39
|
+
|
|
40
|
+
Same JSON as Cursor, in:
|
|
41
|
+
|
|
42
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
43
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
44
|
+
- Linux: `~/.config/Claude/claude_desktop_config.json`
|
|
45
|
+
|
|
46
|
+
### Windsurf (`~/.codeium/windsurf/mcp_config.json`)
|
|
47
|
+
|
|
48
|
+
Same JSON as Cursor.
|
|
49
|
+
|
|
50
|
+
### Smithery
|
|
51
|
+
|
|
52
|
+
The repo ships a root `smithery.yaml`; the hosted config asks for `getlyApiKey` and maps it to `GETLY_API_KEY`.
|
|
53
|
+
|
|
54
|
+
> `npx @getly/mcp init` detects installed clients and offers to write these files for you (merging safely with existing config). `init --print` only prints the snippets.
|
|
55
|
+
|
|
56
|
+
## Tools (18)
|
|
57
|
+
|
|
58
|
+
| Tool | What it does | Hints / gates |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `list_products` | List store products (cursor-paginated, filters) | read-only |
|
|
61
|
+
| `get_product` | Full product detail (files, images, reviews, URLs) | read-only |
|
|
62
|
+
| `create_product` | Create a **draft** product (money = integer cents) | 20/day cap |
|
|
63
|
+
| `update_product` | Edit name/price/description/images/tags | idempotent; cannot publish/archive |
|
|
64
|
+
| `publish_product` | Make a draft publicly purchasable | **requires `confirm: true`** |
|
|
65
|
+
| `archive_product` | Remove a product from sale (soft delete) | **destructive, requires `confirm: true`** |
|
|
66
|
+
| `upload_product_file` | Upload a local file as the buyer download (≤2GB) | slow for large files |
|
|
67
|
+
| `upload_image` | Upload a local image, returns a URL for products/posts | ≤10MB |
|
|
68
|
+
| `create_blog_post` | Markdown blog post; `[product:slug]` embeds a buy card | 5/day cap |
|
|
69
|
+
| `list_blog_posts` | List posts | read-only |
|
|
70
|
+
| `create_coupon` | Percentage or fixed-cents discount | **50%+ requires `confirm: true`**; 30/day cap |
|
|
71
|
+
| `list_coupons` | List coupons | read-only |
|
|
72
|
+
| `create_checkout_link` | Instant pay link, coupon auto-applied | idempotent per (product, coupon, reference) |
|
|
73
|
+
| `get_checkout_link_status` | Poll a link: open / completed / expired | read-only |
|
|
74
|
+
| `list_licenses` | Issued license keys + activations | read-only |
|
|
75
|
+
| `get_sales_stats` | Revenue (cents), sales, per-month breakdown, recent orders | read-only |
|
|
76
|
+
| `search_categories` | Fuzzy search of the public 700+ category tree | read-only, no key needed, cached 1h |
|
|
77
|
+
| `get_store` | Store profile + public URL | read-only |
|
|
78
|
+
|
|
79
|
+
There is intentionally **no bulk-delete tool**, and the model is instructed to get explicit human approval before any confirm-gated call.
|
|
80
|
+
|
|
81
|
+
## Security
|
|
82
|
+
|
|
83
|
+
- **Least-privilege scopes.** Create separate keys per workflow:
|
|
84
|
+
- Catalog management: `read:products`, `write:products`
|
|
85
|
+
- Blogging: `read:posts`, `write:posts` (+ `write:products` for image uploads)
|
|
86
|
+
- Sales bot: `checkout:create`, `read:coupons` (+ `write:coupons` if it mints discounts)
|
|
87
|
+
- Reporting: `read:analytics`, `read:orders`, `read:store`, `read:licenses`
|
|
88
|
+
- **Plaintext config warning.** MCP client config files store `GETLY_API_KEY` in plaintext on your machine. Anyone with access to those files can act on your store. Prefer per-machine keys.
|
|
89
|
+
- **Rotate / revoke** any key you suspect leaked at https://www.getly.store/dashboard/developer/keys (rotation keeps the old token valid for 24h so configs don't break mid-swap).
|
|
90
|
+
- The key never leaves the `Authorization` header of requests to `www.getly.store`; the server never logs or echoes it (setup output masks all but the last 4 characters).
|
|
91
|
+
|
|
92
|
+
## Programmatic use
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { createGetlyMcpServer } from '@getly/mcp';
|
|
96
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
97
|
+
|
|
98
|
+
const server = createGetlyMcpServer();
|
|
99
|
+
await server.connect(new StdioServerTransport());
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm -w packages/mcp run typecheck
|
|
106
|
+
npm -w packages/mcp run build
|
|
107
|
+
npm -w packages/mcp test # includes a real stdio boot test
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
MIT © Getly
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Getly v1 API client used by the MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Conventions (mirror of the platform contract):
|
|
5
|
+
* - Base URL https://www.getly.store (override with GETLY_BASE_URL for tests).
|
|
6
|
+
* - Auth: `Authorization: Bearer <key>` — the key comes ONLY from the
|
|
7
|
+
* GETLY_API_KEY environment variable, never from tool arguments, and is
|
|
8
|
+
* never logged or echoed back.
|
|
9
|
+
* - Responses: `{ success: true, data }` / `{ success: false, error, errorDetail }`
|
|
10
|
+
* where errorDetail = { code, message, hint, docsUrl, param? }.
|
|
11
|
+
* - Money: integer cents only (priceCents / discountedPriceCents / valueCents).
|
|
12
|
+
* - Cursor pagination: { items, nextCursor }.
|
|
13
|
+
* - POST creates send an auto-generated Idempotency-Key.
|
|
14
|
+
*/
|
|
15
|
+
import { Getly } from '@getly/sdk';
|
|
16
|
+
export declare const DEFAULT_BASE_URL = "https://www.getly.store";
|
|
17
|
+
export declare function getBaseUrl(): string;
|
|
18
|
+
/** Read the API key from the environment. Returns null when not configured. */
|
|
19
|
+
export declare function getApiKey(): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Build a @getly/sdk client for the multi-step flows the SDK already solves
|
|
22
|
+
* (presign → PUT → attach uploads). Constructed per call so env changes and
|
|
23
|
+
* test fetch stubs are always picked up.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getClient(): Getly;
|
|
26
|
+
export interface ErrorDetail {
|
|
27
|
+
code: string;
|
|
28
|
+
message: string;
|
|
29
|
+
hint?: string;
|
|
30
|
+
docsUrl?: string;
|
|
31
|
+
param?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface PublishReason {
|
|
34
|
+
code: string;
|
|
35
|
+
detail: string;
|
|
36
|
+
}
|
|
37
|
+
/** Error thrown for any non-success Getly API response. Never contains the key. */
|
|
38
|
+
export declare class GetlyApiError extends Error {
|
|
39
|
+
readonly status: number;
|
|
40
|
+
readonly code: string;
|
|
41
|
+
readonly hint?: string;
|
|
42
|
+
readonly docsUrl?: string;
|
|
43
|
+
readonly param?: string;
|
|
44
|
+
readonly retryAfterSeconds?: number;
|
|
45
|
+
/** Machine-readable publish blockers (422 not_publishable). */
|
|
46
|
+
readonly reasons?: PublishReason[];
|
|
47
|
+
constructor(status: number, detail: ErrorDetail, opts?: {
|
|
48
|
+
retryAfterSeconds?: number;
|
|
49
|
+
reasons?: PublishReason[];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export interface ApiEnvelope<T = unknown> {
|
|
53
|
+
success: true;
|
|
54
|
+
data: T;
|
|
55
|
+
/** Extra top-level keys some endpoints add (warnings, total, page, ...). */
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
export interface RequestOptions {
|
|
59
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
60
|
+
body?: unknown;
|
|
61
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
62
|
+
/** Auto-attach a random Idempotency-Key (use on POST creates). */
|
|
63
|
+
idempotent?: boolean;
|
|
64
|
+
/** Override: allow unauthenticated calls (public endpoints). */
|
|
65
|
+
apiKey?: string | null;
|
|
66
|
+
timeoutMs?: number;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Perform a JSON request against the Getly API and return the full success
|
|
70
|
+
* envelope. Throws GetlyApiError on { success: false } or non-2xx.
|
|
71
|
+
*/
|
|
72
|
+
export declare function apiRequest<T = unknown>(path: string, opts?: RequestOptions): Promise<ApiEnvelope<T>>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Getly v1 API client used by the MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Conventions (mirror of the platform contract):
|
|
5
|
+
* - Base URL https://www.getly.store (override with GETLY_BASE_URL for tests).
|
|
6
|
+
* - Auth: `Authorization: Bearer <key>` — the key comes ONLY from the
|
|
7
|
+
* GETLY_API_KEY environment variable, never from tool arguments, and is
|
|
8
|
+
* never logged or echoed back.
|
|
9
|
+
* - Responses: `{ success: true, data }` / `{ success: false, error, errorDetail }`
|
|
10
|
+
* where errorDetail = { code, message, hint, docsUrl, param? }.
|
|
11
|
+
* - Money: integer cents only (priceCents / discountedPriceCents / valueCents).
|
|
12
|
+
* - Cursor pagination: { items, nextCursor }.
|
|
13
|
+
* - POST creates send an auto-generated Idempotency-Key.
|
|
14
|
+
*/
|
|
15
|
+
import { Getly } from '@getly/sdk';
|
|
16
|
+
export const DEFAULT_BASE_URL = 'https://www.getly.store';
|
|
17
|
+
export function getBaseUrl() {
|
|
18
|
+
const raw = process.env.GETLY_BASE_URL;
|
|
19
|
+
return raw && raw.trim() ? raw.trim().replace(/\/+$/, '') : DEFAULT_BASE_URL;
|
|
20
|
+
}
|
|
21
|
+
/** Read the API key from the environment. Returns null when not configured. */
|
|
22
|
+
export function getApiKey() {
|
|
23
|
+
const key = process.env.GETLY_API_KEY;
|
|
24
|
+
return key && key.trim() ? key.trim() : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a @getly/sdk client for the multi-step flows the SDK already solves
|
|
28
|
+
* (presign → PUT → attach uploads). Constructed per call so env changes and
|
|
29
|
+
* test fetch stubs are always picked up.
|
|
30
|
+
*/
|
|
31
|
+
export function getClient() {
|
|
32
|
+
return new Getly({ baseUrl: getBaseUrl() });
|
|
33
|
+
}
|
|
34
|
+
/** Error thrown for any non-success Getly API response. Never contains the key. */
|
|
35
|
+
export class GetlyApiError extends Error {
|
|
36
|
+
status;
|
|
37
|
+
code;
|
|
38
|
+
hint;
|
|
39
|
+
docsUrl;
|
|
40
|
+
param;
|
|
41
|
+
retryAfterSeconds;
|
|
42
|
+
/** Machine-readable publish blockers (422 not_publishable). */
|
|
43
|
+
reasons;
|
|
44
|
+
constructor(status, detail, opts = {}) {
|
|
45
|
+
super(detail.message);
|
|
46
|
+
this.name = 'GetlyApiError';
|
|
47
|
+
this.status = status;
|
|
48
|
+
this.code = detail.code;
|
|
49
|
+
this.hint = detail.hint;
|
|
50
|
+
this.docsUrl = detail.docsUrl;
|
|
51
|
+
this.param = detail.param;
|
|
52
|
+
this.retryAfterSeconds = opts.retryAfterSeconds;
|
|
53
|
+
this.reasons = opts.reasons;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Perform a JSON request against the Getly API and return the full success
|
|
58
|
+
* envelope. Throws GetlyApiError on { success: false } or non-2xx.
|
|
59
|
+
*/
|
|
60
|
+
export async function apiRequest(path, opts = {}) {
|
|
61
|
+
const url = new URL(path, getBaseUrl() + '/');
|
|
62
|
+
if (opts.query) {
|
|
63
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
64
|
+
if (v !== undefined)
|
|
65
|
+
url.searchParams.set(k, String(v));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const headers = { Accept: 'application/json' };
|
|
69
|
+
const key = opts.apiKey === undefined ? getApiKey() : opts.apiKey;
|
|
70
|
+
if (key)
|
|
71
|
+
headers['Authorization'] = `Bearer ${key}`;
|
|
72
|
+
if (opts.body !== undefined)
|
|
73
|
+
headers['Content-Type'] = 'application/json';
|
|
74
|
+
if (opts.idempotent)
|
|
75
|
+
headers['Idempotency-Key'] = crypto.randomUUID();
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await fetch(url, {
|
|
79
|
+
method: opts.method ?? 'GET',
|
|
80
|
+
headers,
|
|
81
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
82
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 60_000),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new GetlyApiError(0, {
|
|
87
|
+
code: 'network_error',
|
|
88
|
+
message: `Could not reach ${url.host}: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
|
+
hint: 'Check the network connection, then retry the same call.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const json = (await res.json().catch(() => null));
|
|
93
|
+
if (!res.ok || !json || json.success !== true) {
|
|
94
|
+
const detail = (json?.errorDetail ?? {});
|
|
95
|
+
const retryAfterRaw = res.headers.get('retry-after');
|
|
96
|
+
throw new GetlyApiError(res.status, {
|
|
97
|
+
code: detail.code ?? (res.status === 429 ? 'rate_limited' : 'api_error'),
|
|
98
|
+
message: detail.message ??
|
|
99
|
+
(typeof json?.error === 'string' ? json.error : `Request failed with HTTP ${res.status}`),
|
|
100
|
+
hint: detail.hint,
|
|
101
|
+
docsUrl: detail.docsUrl,
|
|
102
|
+
param: detail.param,
|
|
103
|
+
}, {
|
|
104
|
+
retryAfterSeconds: retryAfterRaw ? Number(retryAfterRaw) || undefined : undefined,
|
|
105
|
+
reasons: Array.isArray(json?.reasons) ? json.reasons : undefined,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return json;
|
|
109
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface FlatCategory {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
/** Human-readable ancestry, e.g. "Graphics & Design > Icons". */
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
/** Test hook: clear the in-process cache. */
|
|
9
|
+
export declare function _resetCategoryCache(): void;
|
|
10
|
+
/**
|
|
11
|
+
* Fuzzy score for a category against a query. 0 = no match.
|
|
12
|
+
* Ordering: exact name > name prefix > name substring > all query tokens
|
|
13
|
+
* somewhere in the path > character subsequence of the name.
|
|
14
|
+
*/
|
|
15
|
+
export declare function fuzzyScore(query: string, cat: FlatCategory): number;
|
|
16
|
+
/** Search the category tree. Returns the best `limit` matches, scored. */
|
|
17
|
+
export declare function searchCategories(query: string, limit?: number): Promise<Array<FlatCategory & {
|
|
18
|
+
score: number;
|
|
19
|
+
}>>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Category search: fetches the PUBLIC category tree from
|
|
3
|
+
* GET /api/categories (no auth), flattens it (3 levels), fuzzy-filters
|
|
4
|
+
* locally, and caches the tree in-process for 1 hour.
|
|
5
|
+
*
|
|
6
|
+
* Endpoint shape (ground truth: src/app/api/categories/route.ts):
|
|
7
|
+
* { success: true, data: [{ id, name, slug, parentId, icon?, children: [
|
|
8
|
+
* { id, name, slug, parentId, children: [...] } ] }] }
|
|
9
|
+
*/
|
|
10
|
+
import { getBaseUrl, GetlyApiError } from './api.js';
|
|
11
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
12
|
+
let cache = null;
|
|
13
|
+
/** Test hook: clear the in-process cache. */
|
|
14
|
+
export function _resetCategoryCache() {
|
|
15
|
+
cache = null;
|
|
16
|
+
}
|
|
17
|
+
function flatten(nodes, prefix, out) {
|
|
18
|
+
for (const node of nodes) {
|
|
19
|
+
const path = prefix ? `${prefix} > ${node.name}` : node.name;
|
|
20
|
+
out.push({ id: node.id, name: node.name, slug: node.slug, path });
|
|
21
|
+
if (Array.isArray(node.children) && node.children.length > 0) {
|
|
22
|
+
flatten(node.children, path, out);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function loadCategories() {
|
|
27
|
+
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS)
|
|
28
|
+
return cache.flat;
|
|
29
|
+
const res = await fetch(`${getBaseUrl()}/api/categories`, {
|
|
30
|
+
headers: { Accept: 'application/json' },
|
|
31
|
+
signal: AbortSignal.timeout(30_000),
|
|
32
|
+
});
|
|
33
|
+
const json = (await res.json().catch(() => null));
|
|
34
|
+
if (!res.ok || !json?.success || !Array.isArray(json.data)) {
|
|
35
|
+
throw new GetlyApiError(res.status, {
|
|
36
|
+
code: 'api_error',
|
|
37
|
+
message: 'Failed to load the Getly category tree',
|
|
38
|
+
hint: 'Retry in a few seconds; the endpoint is public and cached.',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const flat = [];
|
|
42
|
+
flatten(json.data, '', flat);
|
|
43
|
+
cache = { fetchedAt: Date.now(), flat };
|
|
44
|
+
return flat;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fuzzy score for a category against a query. 0 = no match.
|
|
48
|
+
* Ordering: exact name > name prefix > name substring > all query tokens
|
|
49
|
+
* somewhere in the path > character subsequence of the name.
|
|
50
|
+
*/
|
|
51
|
+
export function fuzzyScore(query, cat) {
|
|
52
|
+
const q = query.trim().toLowerCase();
|
|
53
|
+
if (!q)
|
|
54
|
+
return 0;
|
|
55
|
+
const name = cat.name.toLowerCase();
|
|
56
|
+
const path = cat.path.toLowerCase();
|
|
57
|
+
const slug = cat.slug.toLowerCase();
|
|
58
|
+
if (name === q || slug === q)
|
|
59
|
+
return 100;
|
|
60
|
+
if (name.startsWith(q))
|
|
61
|
+
return 80;
|
|
62
|
+
if (name.includes(q) || slug.includes(q))
|
|
63
|
+
return 60;
|
|
64
|
+
const tokens = q.split(/[\s/,&>-]+/).filter(Boolean);
|
|
65
|
+
if (tokens.length > 0 && tokens.every((t) => path.includes(t)))
|
|
66
|
+
return 40;
|
|
67
|
+
// Character subsequence over the name (e.g. "grph dsgn" ~ "graphics design").
|
|
68
|
+
let i = 0;
|
|
69
|
+
const compact = q.replace(/\s+/g, '');
|
|
70
|
+
for (const ch of name) {
|
|
71
|
+
if (ch === compact[i])
|
|
72
|
+
i++;
|
|
73
|
+
if (i === compact.length)
|
|
74
|
+
return 20;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
/** Search the category tree. Returns the best `limit` matches, scored. */
|
|
79
|
+
export async function searchCategories(query, limit = 10) {
|
|
80
|
+
const flat = await loadCategories();
|
|
81
|
+
return flat
|
|
82
|
+
.map((cat) => ({ ...cat, score: fuzzyScore(query, cat) }))
|
|
83
|
+
.filter((c) => c.score > 0)
|
|
84
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
|
85
|
+
.slice(0, Math.min(Math.max(limit, 1), 50));
|
|
86
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* getly-mcp CLI:
|
|
4
|
+
* getly-mcp → start the stdio MCP server
|
|
5
|
+
* getly-mcp init → interactive client setup (Cursor/Claude/Windsurf)
|
|
6
|
+
* getly-mcp init --print → print all config snippets, write nothing
|
|
7
|
+
*/
|
|
8
|
+
import { runInit } from './init.js';
|
|
9
|
+
import { startStdioServer } from './server.js';
|
|
10
|
+
const command = process.argv[2];
|
|
11
|
+
if (command === 'init') {
|
|
12
|
+
await runInit(process.argv.slice(3));
|
|
13
|
+
}
|
|
14
|
+
else if (command === '--help' || command === '-h' || command === 'help') {
|
|
15
|
+
process.stderr.write([
|
|
16
|
+
'Usage: getly-mcp [command]',
|
|
17
|
+
'',
|
|
18
|
+
' (no command) start the MCP server on stdio',
|
|
19
|
+
' init set up Claude Code / Cursor / Claude Desktop / Windsurf',
|
|
20
|
+
' init --print print config snippets without writing any files',
|
|
21
|
+
'',
|
|
22
|
+
'The Getly API key is read from the GETLY_API_KEY environment variable.',
|
|
23
|
+
'Create one: https://www.getly.store/dashboard/developer/keys',
|
|
24
|
+
'',
|
|
25
|
+
].join('\n'));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
await startStdioServer();
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createGetlyMcpServer, startStdioServer, SERVER_NAME, SERVER_VERSION } from './server.js';
|
|
2
|
+
export { TOOLS, TOOL_NAMES, MISSING_KEY_MESSAGE } from './tools.js';
|
|
3
|
+
export type { GetlyTool, ToolResult, ToolAnnotations } from './tools.js';
|
|
4
|
+
export { GetlyApiError, getBaseUrl, DEFAULT_BASE_URL } from './api.js';
|
|
5
|
+
export { searchCategories } from './categories.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createGetlyMcpServer, startStdioServer, SERVER_NAME, SERVER_VERSION } from './server.js';
|
|
2
|
+
export { TOOLS, TOOL_NAMES, MISSING_KEY_MESSAGE } from './tools.js';
|
|
3
|
+
export { GetlyApiError, getBaseUrl, DEFAULT_BASE_URL } from './api.js';
|
|
4
|
+
export { searchCategories } from './categories.js';
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Mask a secret: everything but the last 4 chars. Never returns the input. */
|
|
2
|
+
export declare function maskKey(key: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Merge the getly server into an existing MCP JSON config string.
|
|
5
|
+
* Returns the new JSON, or null when the existing file is not valid JSON
|
|
6
|
+
* (we refuse to clobber a file we cannot parse).
|
|
7
|
+
*/
|
|
8
|
+
export declare function mergeMcpConfig(existing: string | null, apiKey: string | null): string | null;
|
|
9
|
+
export declare function runInit(argv: string[]): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @getly/mcp init` — interactive-lite setup for MCP clients.
|
|
3
|
+
*
|
|
4
|
+
* Detects Claude Code, Cursor, Claude Desktop and Windsurf; prints the
|
|
5
|
+
* copy-paste snippet for each and (for JSON-config clients) offers to write
|
|
6
|
+
* the config, merging existing JSON safely. `--print` only prints, never
|
|
7
|
+
* writes. The API key is NEVER echoed in full — masked to the last 4 chars.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { homedir, platform } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { createInterface } from 'node:readline/promises';
|
|
14
|
+
const KEY_URL = 'https://www.getly.store/dashboard/developer/keys';
|
|
15
|
+
const PLACEHOLDER = 'YOUR_GETLY_API_KEY';
|
|
16
|
+
/** Mask a secret: everything but the last 4 chars. Never returns the input. */
|
|
17
|
+
export function maskKey(key) {
|
|
18
|
+
if (key.length <= 4)
|
|
19
|
+
return '****';
|
|
20
|
+
return `${'*'.repeat(Math.min(key.length - 4, 20))}${key.slice(-4)}`;
|
|
21
|
+
}
|
|
22
|
+
function jsonClients() {
|
|
23
|
+
const home = homedir();
|
|
24
|
+
const claudeDesktopDir = platform() === 'darwin'
|
|
25
|
+
? join(home, 'Library', 'Application Support', 'Claude')
|
|
26
|
+
: platform() === 'win32'
|
|
27
|
+
? join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Claude')
|
|
28
|
+
: join(home, '.config', 'Claude');
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
name: 'Cursor',
|
|
32
|
+
configPath: join(home, '.cursor', 'mcp.json'),
|
|
33
|
+
detectPath: join(home, '.cursor'),
|
|
34
|
+
serversKey: 'mcpServers',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Claude Desktop',
|
|
38
|
+
configPath: join(claudeDesktopDir, 'claude_desktop_config.json'),
|
|
39
|
+
detectPath: claudeDesktopDir,
|
|
40
|
+
serversKey: 'mcpServers',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'Windsurf',
|
|
44
|
+
configPath: join(home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
45
|
+
detectPath: join(home, '.codeium', 'windsurf'),
|
|
46
|
+
serversKey: 'mcpServers',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
function serverEntry(key) {
|
|
51
|
+
return {
|
|
52
|
+
command: 'npx',
|
|
53
|
+
args: ['-y', '@getly/mcp'],
|
|
54
|
+
env: { GETLY_API_KEY: key ?? PLACEHOLDER },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Merge the getly server into an existing MCP JSON config string.
|
|
59
|
+
* Returns the new JSON, or null when the existing file is not valid JSON
|
|
60
|
+
* (we refuse to clobber a file we cannot parse).
|
|
61
|
+
*/
|
|
62
|
+
export function mergeMcpConfig(existing, apiKey) {
|
|
63
|
+
let config = {};
|
|
64
|
+
if (existing !== null && existing.trim() !== '') {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(existing);
|
|
67
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
68
|
+
return null;
|
|
69
|
+
config = parsed;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const servers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
|
76
|
+
? config.mcpServers
|
|
77
|
+
: {};
|
|
78
|
+
servers.getly = serverEntry(apiKey);
|
|
79
|
+
config.mcpServers = servers;
|
|
80
|
+
return JSON.stringify(config, null, 2) + '\n';
|
|
81
|
+
}
|
|
82
|
+
function snippetJson(apiKey) {
|
|
83
|
+
return JSON.stringify({ mcpServers: { getly: serverEntry(apiKey ? maskKey(apiKey) : null) } }, null, 2);
|
|
84
|
+
}
|
|
85
|
+
const log = (s = '') => process.stderr.write(s + '\n');
|
|
86
|
+
export async function runInit(argv) {
|
|
87
|
+
const printOnly = argv.includes('--print');
|
|
88
|
+
const apiKey = process.env.GETLY_API_KEY?.trim() || null;
|
|
89
|
+
log('Getly MCP setup');
|
|
90
|
+
log('===============');
|
|
91
|
+
if (apiKey) {
|
|
92
|
+
log(`Found GETLY_API_KEY in the environment (${maskKey(apiKey)}) — configs will use it.`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
log(`No GETLY_API_KEY set. Create a key at ${KEY_URL} (grant only the scopes you need),`);
|
|
96
|
+
log(`then re-run with the env var set, or replace ${PLACEHOLDER} in the snippets below.`);
|
|
97
|
+
}
|
|
98
|
+
log();
|
|
99
|
+
// Claude Code: one command, no file writing needed.
|
|
100
|
+
log('Claude Code — run this in your terminal:');
|
|
101
|
+
log(` claude mcp add getly --env GETLY_API_KEY=${apiKey ? '<your key>' : PLACEHOLDER} -- npx -y @getly/mcp`);
|
|
102
|
+
if (apiKey)
|
|
103
|
+
log(' (substitute <your key> yourself — this tool never prints the full key)');
|
|
104
|
+
log();
|
|
105
|
+
const clients = jsonClients();
|
|
106
|
+
const rl = printOnly ? null : createInterface({ input: process.stdin, output: process.stderr });
|
|
107
|
+
for (const client of clients) {
|
|
108
|
+
const detected = existsSync(client.detectPath);
|
|
109
|
+
log(`${client.name}${detected ? ' (detected)' : ''} — ${client.configPath}:`);
|
|
110
|
+
log(snippetJson(apiKey));
|
|
111
|
+
log();
|
|
112
|
+
if (printOnly || !detected || !rl)
|
|
113
|
+
continue;
|
|
114
|
+
const answer = (await rl.question(`Write this to ${client.configPath}? [y/N] `)).trim().toLowerCase();
|
|
115
|
+
if (answer !== 'y' && answer !== 'yes')
|
|
116
|
+
continue;
|
|
117
|
+
const existing = existsSync(client.configPath)
|
|
118
|
+
? await readFile(client.configPath, 'utf8')
|
|
119
|
+
: null;
|
|
120
|
+
const merged = mergeMcpConfig(existing, apiKey);
|
|
121
|
+
if (merged === null) {
|
|
122
|
+
log(` SKIPPED: ${client.configPath} exists but is not valid JSON — fix it manually, nothing was overwritten.`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
await mkdir(dirname(client.configPath), { recursive: true });
|
|
126
|
+
await writeFile(client.configPath, merged, 'utf8');
|
|
127
|
+
log(` Wrote ${client.configPath}${apiKey ? ` (key ${maskKey(apiKey)})` : ` — replace ${PLACEHOLDER} with your key`}. Restart ${client.name} to load it.`);
|
|
128
|
+
}
|
|
129
|
+
rl?.close();
|
|
130
|
+
log('Security notes:');
|
|
131
|
+
log('- These config files store the key in PLAINTEXT on this machine. Anyone with file access can read it.');
|
|
132
|
+
log(`- Grant least-privilege scopes per workflow, and rotate/revoke keys at ${KEY_URL}.`);
|
|
133
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getly MCP server — stdio transport, programmatic export.
|
|
3
|
+
*/
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
export declare const SERVER_NAME = "getly";
|
|
6
|
+
export declare const SERVER_VERSION = "0.1.0";
|
|
7
|
+
/** Build a configured McpServer with all Getly tools registered. */
|
|
8
|
+
export declare function createGetlyMcpServer(): McpServer;
|
|
9
|
+
/** Start the server on stdio (used by the CLI). Never writes to stdout. */
|
|
10
|
+
export declare function startStdioServer(): Promise<void>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getly MCP server — stdio transport, programmatic export.
|
|
3
|
+
*/
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { TOOLS } from './tools.js';
|
|
7
|
+
export const SERVER_NAME = 'getly';
|
|
8
|
+
export const SERVER_VERSION = '0.1.0';
|
|
9
|
+
const INSTRUCTIONS = [
|
|
10
|
+
'Run the user\'s Getly digital-products store: products, blog posts, coupons, checkout links, license keys, sales stats.',
|
|
11
|
+
'Money is ALWAYS integer cents (priceCents, valueCents).',
|
|
12
|
+
'Destructive or revenue-affecting tools (publish_product, archive_product, create_coupon at 50%+ discount) require confirm: true — always ask the human user before setting it.',
|
|
13
|
+
'The API key is read from the GETLY_API_KEY environment variable only. Never ask the user to paste a key into chat; point them to `npx @getly/mcp init`.',
|
|
14
|
+
].join('\n');
|
|
15
|
+
/** Build a configured McpServer with all Getly tools registered. */
|
|
16
|
+
export function createGetlyMcpServer() {
|
|
17
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { instructions: INSTRUCTIONS });
|
|
18
|
+
for (const tool of TOOLS) {
|
|
19
|
+
server.registerTool(tool.name, {
|
|
20
|
+
description: tool.description,
|
|
21
|
+
inputSchema: tool.inputSchema,
|
|
22
|
+
annotations: { title: tool.annotations.title, ...tool.annotations },
|
|
23
|
+
}, (async (args) => tool.handler(args ?? {})));
|
|
24
|
+
}
|
|
25
|
+
return server;
|
|
26
|
+
}
|
|
27
|
+
/** Start the server on stdio (used by the CLI). Never writes to stdout. */
|
|
28
|
+
export async function startStdioServer() {
|
|
29
|
+
const server = createGetlyMcpServer();
|
|
30
|
+
const transport = new StdioServerTransport();
|
|
31
|
+
await server.connect(transport);
|
|
32
|
+
console.error(`[getly-mcp] server ready (${TOOLS.length} tools). Key configured: ${process.env.GETLY_API_KEY ? 'yes' : 'NO — tools will return setup instructions'}`);
|
|
33
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ZodRawShape } from 'zod';
|
|
2
|
+
export interface ToolResult {
|
|
3
|
+
content: Array<{
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
}>;
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolAnnotations {
|
|
11
|
+
title?: string;
|
|
12
|
+
readOnlyHint?: boolean;
|
|
13
|
+
destructiveHint?: boolean;
|
|
14
|
+
idempotentHint?: boolean;
|
|
15
|
+
openWorldHint?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface GetlyTool {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
annotations: ToolAnnotations;
|
|
21
|
+
inputSchema: ZodRawShape;
|
|
22
|
+
/** Whether the tool needs GETLY_API_KEY (search_categories is public). */
|
|
23
|
+
requiresAuth: boolean;
|
|
24
|
+
handler: (args: Record<string, unknown>) => Promise<ToolResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare const MISSING_KEY_MESSAGE: string;
|
|
27
|
+
export declare const TOOLS: GetlyTool[];
|
|
28
|
+
export declare const TOOL_NAMES: string[];
|