@likyly/mcp-server 0.0.0-stage → 0.4.1

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 CHANGED
@@ -1,3 +1,206 @@
1
- # Temporary Holding Version
1
+ # @likyly/mcp-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lets your AI coding assistant (Claude Code, Cursor, VS Code, Codex, Claude Desktop…) set up
4
+ [LIKYLY](https://likyly.com) recommendations on your site for you.
5
+
6
+ [LIKYLY](https://likyly.com) shows each visitor the products they are most likely to want ("You may
7
+ also like", "Recommended for you"), learned from what visitors do on your site. With this server, you
8
+ ask your assistant in plain words and it does the work:
9
+
10
+ - **connect your catalog**: Shopify, a REST API, a CSV, a webhook, or products pushed from your code;
11
+ - **create a placement**: where recommendations appear and how ("4 related products on the product
12
+ page, exclude out-of-stock");
13
+ - **integrate it into your code** from a recipe written for your stack;
14
+ - **check it works**, from LIKYLY's own data: recommendations served, impressions and clicks received.
15
+
16
+ The quickest way to install it is the wizard, which also installs the SDK and writes your key:
17
+
18
+ ```sh
19
+ npx @likyly/wizard
20
+ ```
21
+
22
+ ## Which key
23
+
24
+ The API decides what a key can do, not the server:
25
+
26
+ - **Public key** (`pk_…`): recommendations and events only. Safe anywhere.
27
+ - **Developer key** (`lk_…`, recommended for assistants) or secret key (`sk_…`): also the admin tools
28
+ (catalog sources, placements, cache). Keep it on your machine or in server-side config, never in
29
+ browser code.
30
+
31
+ Without a developer or secret key, the admin tools answer with a clear 401/403; nothing breaks.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm install -g @likyly/mcp-server
37
+ ```
38
+
39
+ ## Configure
40
+
41
+ **Recommendations/events only** (e.g. Claude Desktop's `claude_desktop_config.json`, or a
42
+ browser-facing use case) - a public key:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "likyly": {
48
+ "command": "likyly-mcp-server",
49
+ "env": {
50
+ "LIKYLY_API_KEY": "your-restricted-public-key",
51
+ "LIKYLY_BASE_URL": "https://api.likyly.com",
52
+ "LIKYLY_PRODUCT_TYPE": "your-catalog-namespace"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ **The easy way: `npx @likyly/wizard`** (or `npx @likyly/wizard mcp add`). It signs you in on
60
+ likyly.com, gets a developer key, stores it in `.likyly/credentials.env` (a folder that ignores
61
+ itself in git, readable by you only) and writes `.mcp.json` / `.cursor/mcp.json` /
62
+ `.vscode/mcp.json` **without any key in them**: they reference `LIKYLY_DEVELOPER_KEY`, and when
63
+ that variable is not exported the server reads the key from the credentials file
64
+ (`LIKYLY_ENV_FILE` to point elsewhere; by default `.likyly/credentials.env` in the folder it
65
+ starts in).
66
+
67
+ **By hand, Claude Code / Codex, with data-source admin tools** - first create a developer key (once,
68
+ from a server-side shell - never from a browser):
69
+
70
+ ```bash
71
+ curl -X POST https://api.likyly.com/clients/me/developer-keys \
72
+ -H "Authorization: Bearer <your Supabase session JWT>" \
73
+ -H "Content-Type: application/json" \
74
+ -d '{"name": "Claude Code", "scopes": ["sources:read", "sources:write", "placements:read", "placements:write"]}'
75
+ # -> {"key": "...", ...} - copy `key`, it is shown once
76
+ ```
77
+
78
+ Then point `.mcp.json` (or `claude_desktop_config.json`) at it:
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "likyly": {
84
+ "command": "likyly-mcp-server",
85
+ "env": {
86
+ "LIKYLY_API_KEY": "your-developer-key",
87
+ "LIKYLY_BASE_URL": "https://api.likyly.com"
88
+ }
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ `LIKYLY_PRODUCT_TYPE` is optional - set it if you only manage one catalog, so prompts don't need
95
+ to repeat it; every recommendation/event tool also accepts `product_type` per-call to override
96
+ it (the admin tools target a data source's own `product_type`, set at `create_data_source`).
97
+
98
+ ## Tools
99
+
100
+ Recommendations/events - work with the public key:
101
+
102
+ | Tool | Maps to |
103
+ |---|---|
104
+ | `get_recommendations` | `POST /getRec` - LIKYLY picks the strategy; returns a `recommendation_id` |
105
+ | `get_popular_recommendations` | `GET /getRec/popular/{count}` |
106
+ | `get_cold_start_recommendations` | `GET /getRec/content/{item_id}/{count}` |
107
+ | `get_collaborative_recommendations` | `GET /getRec/collaborative/{user_id}/{count}` |
108
+ | `get_hybrid_recommendations` | `GET /getRec/hybrid/{user_id}/{item_id}/{count}` |
109
+ | `get_session_recommendations` | `GET /getRec/session` |
110
+ | `get_user_session_recommendations` | `GET /getRec/sessionForUser/{user_id}/{count}` |
111
+ | `track_event` | `POST /events/{event_type}` (impression, view, click, add_to_cart, remove_from_cart, purchase, ...) |
112
+
113
+ Admin - data source integrations - need the secret key or a developer key with `sources:read`/
114
+ `sources:write` (see [`docs/data-sources.md`](https://github.com/Likyly/likyly-recsys/blob/main/docs/data-sources.md) for the full
115
+ architecture, source types and config shapes):
116
+
117
+ | Tool | Maps to |
118
+ |---|---|
119
+ | `list_source_types` | `GET /data-sources/types` |
120
+ | `create_data_source` | `POST /data-sources` |
121
+ | `test_data_source` | `POST /data-sources/{id}/test` |
122
+ | `preview_data_source` | `POST /data-sources/{id}/preview` |
123
+ | `configure_field_mapping` | `PUT /data-sources/{id}/field-mapping` (or the `/dry-run` variant, with `dry_run: true`) |
124
+ | `sync_data_source` | `POST /data-sources/{id}/sync` |
125
+ | `get_sync_status` | `GET /data-sources/{id}/syncs` |
126
+ | `list_data_sources` | `GET /data-sources` |
127
+ | `get_catalog_stats` | `GET /data-sources/{id}/stats` |
128
+ | `update_data_source` | `PATCH /data-sources/{id}` - fix an outdated source rather than create another (Free: one source) |
129
+ | `delete_data_source` | `DELETE /data-sources/{id}` - items already imported stay |
130
+ | `push_items` | `POST /data-sources/{id}/push` (with the source's `X-Push-Secret`) - load a catalog LIKYLY cannot reach (localhost, VPN): the agent reads the products from the project and pushes them |
131
+ | `rotate_push_secret` | `POST /data-sources/{id}/push-secret/rotate` |
132
+
133
+ Admin - placements (the recommended way to wire up recommendations - see
134
+ [`docs/placements.md`](https://github.com/Likyly/likyly-recsys/blob/main/docs/placements.md)) - need the secret key or a developer key
135
+ with `placements:read`/`placements:write`:
136
+
137
+ | Tool | Maps to |
138
+ |---|---|
139
+ | `list_placements` | `GET /placements` |
140
+ | `get_placement` | `GET /placements/{slug}` |
141
+ | `create_placement` | `POST /placements` |
142
+ | `update_placement` | `PATCH /placements/{slug}` |
143
+ | `delete_or_disable_placement` | `POST /placements/{slug}/deactivate` (default) or `DELETE /placements/{slug}` |
144
+ | `preview_placement` | `POST /placements/{slug}/preview` |
145
+ | `get_placement_requirements` | `GET /placements/{slug}/requirements` |
146
+ | `purge_recommendation_cache` | `POST /placements/{slug}/cache/purge`, or `POST /placements/cache/purge` for all - empties the anonymous recommendation cache (see docs/placements.md, "Caching") |
147
+
148
+ Admin - integration recipe & validator (writing and verifying the client-side integration -
149
+ see [`docs/getting-started.md`](https://github.com/Likyly/likyly-recsys/blob/main/docs/getting-started.md)) - same `placements:read`/
150
+ `placements:write` requirement:
151
+
152
+ | Tool | Maps to |
153
+ |---|---|
154
+ | `get_integration_recipe` | Composed from `GET /placements/{slug}/requirements` + `.../tracking-requirements` - not a new endpoint |
155
+ | `get_tracking_requirements` | `GET /placements/{slug}/tracking-requirements` |
156
+ | `validate_integration` | `GET /placements/{slug}/health`, rendered as a ✓/⚠/✗ checklist with next steps |
157
+ | `get_placement_health` | `GET /placements/{slug}/health` (structured data, no rendering) |
158
+ | `get_recent_integration_events` | `GET /placements/{slug}/recent-events` |
159
+ | `get_model_overview` | `GET /models/overview` - trained versions (which one is live, precision@10) and production performance per engine; read-only, `placements:read` |
160
+
161
+ The runtime call, `POST /placements/{slug}/recommend`, is **not an MCP tool** - it's a plain
162
+ HTTP call the site's own code makes, safe with the public key, exactly like `POST /getRec`.
163
+
164
+ ## Hosted service (connect any LLM by URL)
165
+
166
+ Besides the local stdio server above, the same tools can run as a hosted **Streamable HTTP**
167
+ service, so an MCP-capable LLM client connects with a URL instead of running a local process:
168
+
169
+ ```bash
170
+ npm run build
171
+ PORT=3200 npm run start:http # POST /mcp, GET /health
172
+ ```
173
+
174
+ Each request carries the caller's own key (public, secret, or developer) - the service stores
175
+ no secrets and forwards it as-is to the LIKYLY API, so it can never do more than the key itself
176
+ allows. Only send a secret or developer key over this path from a trusted server-side caller,
177
+ same as anywhere else - never from browser-facing code:
178
+
179
+ ```
180
+ POST https://mcp.likyly.com/mcp
181
+ Authorization: Bearer <public-secret-or-developer-key>
182
+ X-Likyly-Product-Type: <catalog-namespace> # optional default catalog, recommendation/event tools only
183
+ ```
184
+
185
+ Deploy on the droplet next to the API (Docker + Caddy):
186
+
187
+ ```bash
188
+ docker build -t likyly-mcp sdk/mcp && docker run -d --restart=always -p 127.0.0.1:3200:3200 likyly-mcp
189
+ ```
190
+
191
+ ```caddyfile
192
+ mcp.likyly.com {
193
+ reverse_proxy 127.0.0.1:3200
194
+ }
195
+ ```
196
+
197
+ Env: `PORT` (default 3200), `LIKYLY_BASE_URL` (default `https://api.likyly.com`). Stateless mode:
198
+ no sessions, `GET /mcp` returns 405.
199
+
200
+ ## Develop locally
201
+
202
+ ```bash
203
+ npm install
204
+ npm run build
205
+ node dist/index.js # reads LIKYLY_API_KEY / LIKYLY_BASE_URL / LIKYLY_PRODUCT_TYPE from env
206
+ ```
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { LikylyClient } from "./client.js";
3
+ export declare function createLikylyAdminTools(server: McpServer, client: LikylyClient): void;
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ // Admin tools: configuring and running data-source integrations (Shopify, a REST API, a
3
+ // CSV, a webhook, ...) - real, tenant-scoped configuration changes, not documentation. They
4
+ // need the secret key or a developer key carrying the matching scope (sources:read /
5
+ // sources:write - see /clients/me/developer-keys in the recsys API), never the restricted
6
+ // public key: whatever LIKYLY_API_KEY this server was started with is forwarded as-is, and
7
+ // the API itself is what enforces the scope - a caller using the wrong key gets a clear
8
+ // 401/403 back through the tool result, exactly like every other tool here.
9
+ //
10
+ // A typical flow: list_source_types -> create_data_source -> test_data_source ->
11
+ // preview_data_source -> configure_field_mapping -> sync_data_source -> get_sync_status /
12
+ // get_catalog_stats. Each tool below states which step(s) of that flow it is.
13
+ export function createLikylyAdminTools(server, client) {
14
+ function textResult(data) {
15
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
16
+ }
17
+ const dataSourceId = z.number().int().describe("The data source's id, from create_data_source or list_data_sources.");
18
+ server.tool("list_source_types", "Step 1: every catalog source type LIKYLY can connect to (csv_upload, csv_url, json_url, rest_api, shopify, woocommerce, webhook) - each entry says whether it needs credentials and whether it supports incremental sync. Call this before create_data_source to pick the right `type` and know what `config`/`credentials` it expects (see docs/data-sources.md for each type's exact shape).", {}, async () => textResult(await client.listSourceTypes()));
19
+ server.tool("create_data_source", "Step 2: register a new catalog integration for this account. Doesn't fetch or write anything yet - it just saves the configuration. `product_type` is the catalog namespace it will write into (an existing one, or a new name to start a new catalog). `config` holds non-secret settings (e.g. {base_url, items_path} for rest_api, {shop_domain} for shopify); `credentials` holds secrets (e.g. {access_token} for shopify) and is encrypted at rest, never returned afterwards. Omit `sync_mode` to get the type's default. For a push-mode type (woocommerce, webhook) the response includes a one-time `push_secret` for the upstream system's webhook - save it, it can't be retrieved again. Call test_data_source next.", {
20
+ name: z.string().min(1).max(200),
21
+ type: z.string().describe("One of list_source_types' ids, e.g. 'shopify', 'rest_api', 'csv_url'."),
22
+ product_type: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).describe("The catalog namespace this source writes into."),
23
+ config: z.record(z.unknown()).optional(),
24
+ credentials: z.record(z.unknown()).optional().describe("Secrets - access tokens, API keys. Encrypted at rest."),
25
+ field_mapping: z.record(z.unknown()).optional().describe("Can be set later via configure_field_mapping instead."),
26
+ sync_mode: z.enum(["full", "incremental"]).optional(),
27
+ }, async (input) => textResult(await client.createDataSource(input)));
28
+ server.tool("update_data_source", "Change an existing data source instead of creating another one (the Free plan allows a single source): its name, `config` (merged shallowly: send only the keys that change, e.g. a new base_url), `credentials` (fully replaced) or `product_type`. Use it when a source points at an old URL or catalog. Run test_data_source afterwards.", {
29
+ data_source_id: dataSourceId,
30
+ name: z.string().min(1).max(200).optional(),
31
+ product_type: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/).optional(),
32
+ config: z.record(z.unknown()).optional(),
33
+ credentials: z.record(z.unknown()).optional(),
34
+ sync_mode: z.enum(["full", "incremental"]).optional(),
35
+ }, async ({ data_source_id, ...input }) => textResult(await client.updateDataSource(data_source_id, input)));
36
+ server.tool("delete_data_source", "Delete a data source's configuration and sync history - catalog items it already wrote stay in place. Frees the slot on plans limited to one source. Irreversible: confirm with the developer first, and prefer update_data_source when the source only needs a new URL or catalog.", { data_source_id: dataSourceId }, async ({ data_source_id }) => textResult(await client.deleteDataSource(data_source_id)));
37
+ server.tool("push_items", "Send catalog items to a push-mode source (type 'webhook'), up to 1000 per call. This is the way to load a catalog LIKYLY cannot fetch - a site on localhost, behind a VPN, or data only in the codebase: read the products from the project (API route, JSON, database seed), map them to item objects, push them. `push_secret` comes from create_data_source's response (shown once) or rotate_push_secret. `deleted_ids` removes items.", {
38
+ data_source_id: dataSourceId,
39
+ push_secret: z.string().min(1),
40
+ items: z.array(z.record(z.unknown())).max(1000),
41
+ deleted_ids: z.array(z.string()).max(1000).optional(),
42
+ }, async ({ data_source_id, push_secret, items, deleted_ids }) => textResult(await client.pushItems(data_source_id, push_secret, items, deleted_ids)));
43
+ server.tool("rotate_push_secret", "Issue a new push secret for a push-mode source (the old one stops working) - use it when the secret from create_data_source was lost, then call push_items with the new one.", { data_source_id: dataSourceId }, async ({ data_source_id }) => textResult(await client.rotatePushSecret(data_source_id)));
44
+ server.tool("test_data_source", "Step 3: validates that this source's credentials/config actually reach it - never writes any catalog data. Always returns ok:true/false with a message (never throws for a bad connection), so you can report exactly why a connection failed.", { data_source_id: dataSourceId }, async ({ data_source_id }) => textResult(await client.testDataSource(data_source_id)));
45
+ server.tool("preview_data_source", "Step 4: fetches a small sample from the source (never writes to the catalog) and returns detected_fields plus a best-effort suggested_mapping - the starting point for configure_field_mapping. Call this before mapping fields, especially for a source type you haven't configured before.", { data_source_id: dataSourceId, limit: z.number().int().min(1).max(50).default(10) }, async ({ data_source_id, limit }) => textResult(await client.previewDataSource(data_source_id, limit)));
46
+ server.tool("configure_field_mapping", "Step 5: sets how this source's raw records map to a Likyly item. `mapping.external_id` and `mapping.title` are required; `description`, `category`, `price`, `image`, `url`, `stock`, `updated_at` are optional; `attributes` is itself {name: path} for anything else to keep as free-form properties. Each value is a field name or a dotted/bracket path into a nested record (e.g. 'variants[0].price', 'images[0].src') - start from preview_data_source's suggested_mapping. Pass dry_run:true to see the mapping's effect on a fresh sample WITHOUT saving it (use this to iterate); dry_run:false (the default) persists it for sync_data_source to use.", {
47
+ data_source_id: dataSourceId,
48
+ mapping: z.record(z.unknown()).describe("e.g. {external_id: 'id', title: 'name', price: 'variants[0].price', image: 'images[0].src', category: 'product_type', stock: 'variants[0].inventory_quantity'}"),
49
+ dry_run: z.boolean().default(false).describe("true: preview the effect without saving. false: save it."),
50
+ }, async ({ data_source_id, mapping, dry_run }) => textResult(dry_run ? await client.dryRunFieldMapping(data_source_id, mapping) : await client.saveFieldMapping(data_source_id, mapping)));
51
+ server.tool("sync_data_source", "Step 6: runs a sync now (queued as a background job, so this returns almost immediately with the run's status - poll get_sync_status for completion). `mode` defaults to incremental (falls back to full automatically if the source doesn't support incremental); use mode:'full' explicitly for the first sync or to re-baseline. Not applicable to push-mode sources (woocommerce, webhook) - those sync automatically whenever the upstream system pushes; check get_sync_status/get_catalog_stats for their activity instead.", { data_source_id: dataSourceId, mode: z.enum(["full", "incremental"]).default("incremental") }, async ({ data_source_id, mode }) => textResult(await client.syncDataSource(data_source_id, mode)));
52
+ server.tool("get_sync_status", "Step 7: the most recent sync attempts for this source - status (running/success/partial/failed), items fetched/upserted/deleted/failed, and any error. Poll this right after sync_data_source until status is no longer 'running'. Also the right call to check on a push-mode source's activity.", { data_source_id: dataSourceId, limit: z.number().int().min(1).max(100).default(5) }, async ({ data_source_id, limit }) => textResult(await client.listSyncs(data_source_id, limit)));
53
+ server.tool("list_data_sources", "Every data source configured for this account, with its type, status, and last sync time - use this to check what already exists before creating a duplicate, or to find a data_source_id by name.", {}, async () => textResult(await client.listDataSources()));
54
+ server.tool("get_catalog_stats", "Item count and last-write time for a source's target catalog, plus its latest sync run - what you need to report back to the user after a sync (\"X items synced, Y rejected, next sync at ...\").", { data_source_id: dataSourceId }, async ({ data_source_id }) => textResult(await client.getCatalogStats(data_source_id)));
55
+ }
@@ -0,0 +1,74 @@
1
+ export interface LikylyClientOptions {
2
+ baseUrl: string;
3
+ apiKey: string;
4
+ }
5
+ export declare class LikylyClient {
6
+ private readonly baseUrl;
7
+ private readonly apiKey;
8
+ constructor(options: LikylyClientOptions);
9
+ /** The recommendation call: LIKYLY picks the strategy. */
10
+ getRecommendations(productType: string, request: {
11
+ user_id?: string;
12
+ session_id?: string;
13
+ item_id?: string;
14
+ viewed_item_ids?: string[];
15
+ placement?: string;
16
+ count?: number;
17
+ }): Promise<any>;
18
+ getColdStartRecs(productType: string, itemId: string, count?: number): Promise<any>;
19
+ getPopularRecs(productType: string, count?: number): Promise<any>;
20
+ getUserRecs(productType: string, userId: string, count?: number): Promise<any>;
21
+ getHybridRecs(productType: string, userId: string, itemId: string, count?: number, alpha?: number): Promise<any>;
22
+ getSessionRecs(productType: string, viewedItemIds: string[], count?: number): Promise<any>;
23
+ getUserSessionRecs(productType: string, userId: string, count?: number): Promise<any>;
24
+ listSourceTypes(): Promise<any>;
25
+ createDataSource(input: {
26
+ name: string;
27
+ type: string;
28
+ product_type: string;
29
+ config?: Record<string, unknown>;
30
+ credentials?: Record<string, unknown>;
31
+ field_mapping?: Record<string, unknown>;
32
+ sync_mode?: string;
33
+ }): Promise<any>;
34
+ listDataSources(): Promise<any>;
35
+ getDataSource(dataSourceId: number): Promise<any>;
36
+ updateDataSource(dataSourceId: number, input: Record<string, unknown>): Promise<any>;
37
+ deleteDataSource(dataSourceId: number): Promise<any>;
38
+ /** Push-mode sources (webhook, woocommerce): authenticated by the source's push secret. */
39
+ pushItems(dataSourceId: number, pushSecret: string, items: Record<string, unknown>[], deletedIds?: string[]): Promise<any>;
40
+ rotatePushSecret(dataSourceId: number): Promise<any>;
41
+ testDataSource(dataSourceId: number): Promise<any>;
42
+ previewDataSource(dataSourceId: number, limit?: number): Promise<any>;
43
+ dryRunFieldMapping(dataSourceId: number, mapping: Record<string, unknown>, limit?: number): Promise<any>;
44
+ saveFieldMapping(dataSourceId: number, mapping: Record<string, unknown>): Promise<any>;
45
+ syncDataSource(dataSourceId: number, mode?: string): Promise<any>;
46
+ listSyncs(dataSourceId: number, limit?: number): Promise<any>;
47
+ getCatalogStats(dataSourceId: number): Promise<any>;
48
+ listPlacements(): Promise<any>;
49
+ createPlacement(input: Record<string, unknown>): Promise<any>;
50
+ getPlacement(slug: string): Promise<any>;
51
+ updatePlacement(slug: string, input: Record<string, unknown>): Promise<any>;
52
+ deactivatePlacement(slug: string): Promise<any>;
53
+ deletePlacement(slug: string): Promise<any>;
54
+ purgeRecommendationCache(slug?: string): Promise<any>;
55
+ getPlacementRequirements(slug: string): Promise<any>;
56
+ previewPlacement(slug: string, context: Record<string, unknown>, limit?: number): Promise<any>;
57
+ getTrackingRequirements(slug: string): Promise<any>;
58
+ getPlacementHealth(slug: string, windowHours?: number): Promise<any>;
59
+ /** Read-only model report for one catalog: trained versions (which is live) and production performance per engine. */
60
+ getModelOverview(productType: string, days?: number): Promise<any>;
61
+ getRecentIntegrationEvents(slug: string, windowHours?: number, limit?: number): Promise<any>;
62
+ /** impression, view, click, add_to_cart, remove_from_cart, purchase - or any type of your own. */
63
+ trackEvent(productType: string, eventType: string, event: {
64
+ item_id: string;
65
+ user_id?: string;
66
+ session_id?: string;
67
+ recommendation_id?: string;
68
+ placement?: string;
69
+ quantity?: number;
70
+ properties?: Record<string, unknown>;
71
+ event_id?: string;
72
+ }): Promise<any>;
73
+ private request;
74
+ }
package/dist/client.js ADDED
@@ -0,0 +1,189 @@
1
+ // HTTP client for the LIKYLY API surface this MCP server exposes as tools. Two tiers, both
2
+ // just forwarding whatever key the process was started with - this client never decides what
3
+ // it's allowed to do, the API does:
4
+ // - Recommendations/events (getRecommendations..trackEvent below) - safe with the
5
+ // restricted public key (see the recsys API's get_current_client_id_public_ok scope).
6
+ // - Data sources (listSourceTypes..getCatalogStats below) - needs the secret key or a
7
+ // developer key with the right scope (see /clients/me/developer-keys); a public-key
8
+ // caller gets a clear 401/403 from the API itself.
9
+ // Deliberately standalone rather than depending on ../../js (not published to npm yet) - same
10
+ // request shape as sdk/js/src/client.ts, trimmed to just this tool surface.
11
+ export class LikylyClient {
12
+ baseUrl;
13
+ apiKey;
14
+ constructor(options) {
15
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
16
+ this.apiKey = options.apiKey;
17
+ }
18
+ /** The recommendation call: LIKYLY picks the strategy. */
19
+ async getRecommendations(productType, request) {
20
+ return this.request("POST", "/getRec", { query: { data_product_type: productType }, body: request });
21
+ }
22
+ async getColdStartRecs(productType, itemId, count = 4) {
23
+ return this.request("GET", `/getRec/content/${encodeURIComponent(itemId)}/${count}`, {
24
+ query: { data_product_type: productType, response_format: "object" },
25
+ });
26
+ }
27
+ async getPopularRecs(productType, count = 4) {
28
+ return this.request("GET", `/getRec/popular/${count}`, {
29
+ query: { data_product_type: productType, response_format: "object" },
30
+ });
31
+ }
32
+ async getUserRecs(productType, userId, count = 3) {
33
+ return this.request("GET", `/getRec/collaborative/${encodeURIComponent(userId)}/${count}`, {
34
+ query: { data_product_type: productType, response_format: "object" },
35
+ });
36
+ }
37
+ async getHybridRecs(productType, userId, itemId, count = 3, alpha = 0.5) {
38
+ return this.request("GET", `/getRec/hybrid/${encodeURIComponent(userId)}/${encodeURIComponent(itemId)}/${count}`, {
39
+ query: { data_product_type: productType, alpha, response_format: "object" },
40
+ });
41
+ }
42
+ async getSessionRecs(productType, viewedItemIds, count = 3) {
43
+ return this.request("GET", "/getRec/session", {
44
+ query: { data_product_type: productType, viewed_item_ids: viewedItemIds.join(","), count, response_format: "object" },
45
+ });
46
+ }
47
+ async getUserSessionRecs(productType, userId, count = 3) {
48
+ return this.request("GET", `/getRec/sessionForUser/${encodeURIComponent(userId)}/${count}`, {
49
+ query: { data_product_type: productType, response_format: "object" },
50
+ });
51
+ }
52
+ // ---------------------------------------------------------------------------------------
53
+ // Data sources - admin surface, only usable with the secret key or a developer key
54
+ // carrying the right scope (see /clients/me/developer-keys). Every call below just
55
+ // forwards to the recsys API's /data-sources* routes; the API is the source of truth for
56
+ // what's allowed - a public-key caller gets a clear 401/403 from it, same as any other
57
+ // tool here would.
58
+ // ---------------------------------------------------------------------------------------
59
+ async listSourceTypes() {
60
+ return this.request("GET", "/data-sources/types");
61
+ }
62
+ async createDataSource(input) {
63
+ return this.request("POST", "/data-sources", { body: input });
64
+ }
65
+ async listDataSources() {
66
+ return this.request("GET", "/data-sources");
67
+ }
68
+ async getDataSource(dataSourceId) {
69
+ return this.request("GET", `/data-sources/${dataSourceId}`);
70
+ }
71
+ async updateDataSource(dataSourceId, input) {
72
+ return this.request("PATCH", `/data-sources/${dataSourceId}`, { body: input });
73
+ }
74
+ async deleteDataSource(dataSourceId) {
75
+ return this.request("DELETE", `/data-sources/${dataSourceId}`);
76
+ }
77
+ /** Push-mode sources (webhook, woocommerce): authenticated by the source's push secret. */
78
+ async pushItems(dataSourceId, pushSecret, items, deletedIds = []) {
79
+ return this.request("POST", `/data-sources/${dataSourceId}/push`, {
80
+ body: { items, deleted_ids: deletedIds },
81
+ headers: { "X-Push-Secret": pushSecret },
82
+ });
83
+ }
84
+ async rotatePushSecret(dataSourceId) {
85
+ return this.request("POST", `/data-sources/${dataSourceId}/push-secret/rotate`);
86
+ }
87
+ async testDataSource(dataSourceId) {
88
+ return this.request("POST", `/data-sources/${dataSourceId}/test`);
89
+ }
90
+ async previewDataSource(dataSourceId, limit) {
91
+ return this.request("POST", `/data-sources/${dataSourceId}/preview`, { query: { limit } });
92
+ }
93
+ async dryRunFieldMapping(dataSourceId, mapping, limit) {
94
+ return this.request("POST", `/data-sources/${dataSourceId}/field-mapping/dry-run`, {
95
+ query: { limit }, body: { mapping },
96
+ });
97
+ }
98
+ async saveFieldMapping(dataSourceId, mapping) {
99
+ return this.request("PUT", `/data-sources/${dataSourceId}/field-mapping`, { body: { mapping } });
100
+ }
101
+ async syncDataSource(dataSourceId, mode) {
102
+ return this.request("POST", `/data-sources/${dataSourceId}/sync`, { body: { mode: mode ?? "incremental" } });
103
+ }
104
+ async listSyncs(dataSourceId, limit) {
105
+ return this.request("GET", `/data-sources/${dataSourceId}/syncs`, { query: { limit } });
106
+ }
107
+ async getCatalogStats(dataSourceId) {
108
+ return this.request("GET", `/data-sources/${dataSourceId}/stats`);
109
+ }
110
+ // ---------------------------------------------------------------------------------------
111
+ // Placements - the recommended orchestration abstraction over the strategies above. Admin
112
+ // calls (all but recommend) need the secret key or a developer key with placements:read/
113
+ // placements:write; the actual PlacementRecommendResponse/Request shapes are documented in
114
+ // docs/placements.md, not repeated here.
115
+ // ---------------------------------------------------------------------------------------
116
+ async listPlacements() {
117
+ return this.request("GET", "/placements");
118
+ }
119
+ async createPlacement(input) {
120
+ return this.request("POST", "/placements", { body: input });
121
+ }
122
+ async getPlacement(slug) {
123
+ return this.request("GET", `/placements/${encodeURIComponent(slug)}`);
124
+ }
125
+ async updatePlacement(slug, input) {
126
+ return this.request("PATCH", `/placements/${encodeURIComponent(slug)}`, { body: input });
127
+ }
128
+ async deactivatePlacement(slug) {
129
+ return this.request("POST", `/placements/${encodeURIComponent(slug)}/deactivate`);
130
+ }
131
+ async deletePlacement(slug) {
132
+ return this.request("DELETE", `/placements/${encodeURIComponent(slug)}`);
133
+ }
134
+ async purgeRecommendationCache(slug) {
135
+ return this.request("POST", slug ? `/placements/${encodeURIComponent(slug)}/cache/purge` : "/placements/cache/purge");
136
+ }
137
+ async getPlacementRequirements(slug) {
138
+ return this.request("GET", `/placements/${encodeURIComponent(slug)}/requirements`);
139
+ }
140
+ async previewPlacement(slug, context, limit) {
141
+ return this.request("POST", `/placements/${encodeURIComponent(slug)}/preview`, { body: { context, limit } });
142
+ }
143
+ async getTrackingRequirements(slug) {
144
+ return this.request("GET", `/placements/${encodeURIComponent(slug)}/tracking-requirements`);
145
+ }
146
+ async getPlacementHealth(slug, windowHours) {
147
+ return this.request("GET", `/placements/${encodeURIComponent(slug)}/health`, { query: { window_hours: windowHours } });
148
+ }
149
+ /** Read-only model report for one catalog: trained versions (which is live) and production performance per engine. */
150
+ async getModelOverview(productType, days) {
151
+ return this.request("GET", "/models/overview", { query: { data_product_type: productType, days } });
152
+ }
153
+ async getRecentIntegrationEvents(slug, windowHours, limit) {
154
+ return this.request("GET", `/placements/${encodeURIComponent(slug)}/recent-events`, { query: { window_hours: windowHours, limit } });
155
+ }
156
+ /** impression, view, click, add_to_cart, remove_from_cart, purchase - or any type of your own. */
157
+ async trackEvent(productType, eventType, event) {
158
+ return this.request("POST", `/events/${encodeURIComponent(eventType)}`, {
159
+ query: { data_product_type: productType },
160
+ body: event,
161
+ });
162
+ }
163
+ async request(method, path, opts = {}) {
164
+ const url = new URL(this.baseUrl + path);
165
+ if (opts.query) {
166
+ for (const [key, value] of Object.entries(opts.query)) {
167
+ if (value !== undefined && value !== null)
168
+ url.searchParams.set(key, String(value));
169
+ }
170
+ }
171
+ const headers = { "X-API-Key": this.apiKey, ...opts.headers };
172
+ if (opts.body !== undefined)
173
+ headers["Content-Type"] = "application/json";
174
+ const response = await fetch(url.toString(), {
175
+ method,
176
+ headers,
177
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
178
+ });
179
+ const text = await response.text();
180
+ const data = text ? JSON.parse(text) : null;
181
+ if (!response.ok) {
182
+ const detail = data && typeof data === "object" && "detail" in data
183
+ ? String(data.detail)
184
+ : response.statusText;
185
+ throw new Error(`LIKYLY API error (${response.status}): ${detail}`);
186
+ }
187
+ return data;
188
+ }
189
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // Hosted MCP service: the same tools as the local stdio server, exposed over MCP's
3
+ // Streamable HTTP transport so any MCP-capable LLM client can connect by URL
4
+ // (e.g. https://mcp.likyly.com/mcp) instead of running a local process.
5
+ //
6
+ // Stateless and multi-tenant: every request carries the caller's own *restricted public*
7
+ // key (Authorization: Bearer <key>, or X-Likyly-Api-Key). The service stores no secrets and
8
+ // simply forwards that key to the LIKYLY API, so a caller can never do more through MCP
9
+ // than they could with the key directly.
10
+ import { createServer } from "node:http";
11
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12
+ import { LikylyClient } from "./client.js";
13
+ import { createLikylyServer } from "./tools.js";
14
+ const port = Number(process.env.PORT ?? 3200);
15
+ const baseUrl = process.env.LIKYLY_BASE_URL ?? "https://api.likyly.com";
16
+ const MAX_BODY_BYTES = 1_000_000;
17
+ function setCors(res) {
18
+ res.setHeader("Access-Control-Allow-Origin", "*");
19
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Likyly-Api-Key, X-Likyly-Product-Type, Mcp-Session-Id, Mcp-Protocol-Version");
20
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
21
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
22
+ }
23
+ function sendJson(res, status, body) {
24
+ res.writeHead(status, { "Content-Type": "application/json" });
25
+ res.end(JSON.stringify(body));
26
+ }
27
+ function jsonRpcError(res, status, code, message) {
28
+ sendJson(res, status, { jsonrpc: "2.0", error: { code, message }, id: null });
29
+ }
30
+ function extractApiKey(req) {
31
+ const auth = req.headers.authorization;
32
+ if (auth?.toLowerCase().startsWith("bearer "))
33
+ return auth.slice(7).trim() || undefined;
34
+ const header = req.headers["x-likyly-api-key"];
35
+ return (Array.isArray(header) ? header[0] : header)?.trim() || undefined;
36
+ }
37
+ async function readJson(req) {
38
+ const chunks = [];
39
+ let size = 0;
40
+ for await (const chunk of req) {
41
+ size += chunk.length;
42
+ if (size > MAX_BODY_BYTES)
43
+ throw new Error("Request body too large");
44
+ chunks.push(chunk);
45
+ }
46
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
47
+ }
48
+ const server = createServer(async (req, res) => {
49
+ setCors(res);
50
+ const path = (req.url ?? "/").split("?")[0];
51
+ if (req.method === "OPTIONS") {
52
+ res.writeHead(204).end();
53
+ return;
54
+ }
55
+ if (path === "/health") {
56
+ sendJson(res, 200, { status: "ok", service: "likyly-mcp" });
57
+ return;
58
+ }
59
+ if (path !== "/mcp") {
60
+ sendJson(res, 404, { error: "Not found. The MCP endpoint is /mcp." });
61
+ return;
62
+ }
63
+ // Stateless mode: no server-initiated streams or sessions to resume.
64
+ if (req.method !== "POST") {
65
+ res.setHeader("Allow", "POST, OPTIONS");
66
+ jsonRpcError(res, 405, -32000, "Method not allowed - use POST.");
67
+ return;
68
+ }
69
+ const apiKey = extractApiKey(req);
70
+ if (!apiKey) {
71
+ res.setHeader("WWW-Authenticate", 'Bearer realm="likyly-mcp"');
72
+ jsonRpcError(res, 401, -32001, "Missing API key. Send your restricted public key as 'Authorization: Bearer <key>'.");
73
+ return;
74
+ }
75
+ let body;
76
+ try {
77
+ body = await readJson(req);
78
+ }
79
+ catch {
80
+ jsonRpcError(res, 400, -32700, "Invalid or oversized JSON body.");
81
+ return;
82
+ }
83
+ const productHeader = req.headers["x-likyly-product-type"];
84
+ const defaultProductType = (Array.isArray(productHeader) ? productHeader[0] : productHeader)?.trim() || undefined;
85
+ const mcp = createLikylyServer(new LikylyClient({ baseUrl, apiKey }), defaultProductType);
86
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
87
+ res.on("close", () => {
88
+ void transport.close();
89
+ void mcp.close();
90
+ });
91
+ try {
92
+ await mcp.connect(transport);
93
+ await transport.handleRequest(req, res, body);
94
+ }
95
+ catch (err) {
96
+ console.error("MCP request failed:", err);
97
+ if (!res.headersSent)
98
+ jsonRpcError(res, 500, -32603, "Internal server error.");
99
+ }
100
+ });
101
+ server.listen(port, () => {
102
+ console.error(`likyly-mcp HTTP service listening on :${port} (POST /mcp) -> ${baseUrl}`);
103
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { LikylyClient } from "./client.js";
4
+ import { createLikylyServer } from "./tools.js";
5
+ import { readFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ /**
8
+ * The key, from the environment or else from the project's credentials file written by
9
+ * `npx @likyly/wizard` (`.likyly/credentials.env`, gitignored): editors expand
10
+ * `${LIKYLY_DEVELOPER_KEY}` to an empty string when it is not exported, so an empty or
11
+ * unexpanded value falls through to the file.
12
+ */
13
+ function resolveApiKey() {
14
+ const fromEnv = process.env.LIKYLY_API_KEY?.trim();
15
+ if (fromEnv && !fromEnv.startsWith("${"))
16
+ return fromEnv;
17
+ const file = resolve(process.env.LIKYLY_ENV_FILE || ".likyly/credentials.env");
18
+ try {
19
+ for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
20
+ const m = line.match(/^\s*(LIKYLY_DEVELOPER_KEY|LIKYLY_API_KEY)\s*=\s*["']?([^"'\s#]+)/);
21
+ if (m)
22
+ return m[2];
23
+ }
24
+ }
25
+ catch {
26
+ // No credentials file: the error below explains how to provide a key.
27
+ }
28
+ return undefined;
29
+ }
30
+ const apiKey = resolveApiKey();
31
+ if (!apiKey) {
32
+ console.error("LIKYLY_API_KEY is not set. For recommendations/event tracking only, use your restricted " +
33
+ "public key (account page's \"Clé publique\"). Running here in Claude Code, Codex or " +
34
+ "another coding agent and want the data-source admin tools (create_data_source, " +
35
+ "sync_data_source, ...) too? Use a developer key instead (create one with " +
36
+ "POST /clients/me/developer-keys, scopes sources:read + sources:write) - never a secret " +
37
+ "or developer key in a browser-facing environment, since it may not be a trusted one.");
38
+ process.exit(1);
39
+ }
40
+ const baseUrl = process.env.LIKYLY_BASE_URL ?? "https://api.likyly.com";
41
+ // Most LIKYLY tenants only ever manage one catalog namespace - this lets a vibe coder
42
+ // configure it once instead of repeating it in every prompt/tool call, while still
43
+ // allowing an override per-call for multi-catalog setups.
44
+ const defaultProductType = process.env.LIKYLY_PRODUCT_TYPE;
45
+ const server = createLikylyServer(new LikylyClient({ baseUrl, apiKey }), defaultProductType);
46
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { LikylyClient } from "./client.js";
3
+ export declare function createLikylyPlacementTools(server: McpServer, client: LikylyClient): void;
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
2
+ // Placement tools: the recommended way to wire LIKYLY recommendations into a site. A
3
+ // Placement is a named, persistent config ("what to recommend, where, with what fallback and
4
+ // filters") - create one, preview it, then the site calls it by slug from
5
+ // POST /placements/{slug}/recommend (not an MCP tool - that's a runtime HTTP call the site's
6
+ // own code makes, safe with the public key). These tools need the secret key or a developer
7
+ // key with placements:read/placements:write - a caller using the wrong key gets a clear
8
+ // 401/403 back through the tool result, same as the data-source admin tools.
9
+ //
10
+ // No LLM call happens in LIKYLY for any of this: turning a developer's natural-language
11
+ // request ("4 related products on a PDP, session-based for anonymous visitors...") into the
12
+ // parameters below is exactly what the coding agent calling these tools already does -
13
+ // that's the point of exposing deterministic primitives instead of another prompt layer.
14
+ export function createLikylyPlacementTools(server, client) {
15
+ function textResult(data) {
16
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
17
+ }
18
+ const slug = z.string().min(1).max(128).describe("The placement's slug, e.g. 'pdp-related'.");
19
+ const strategy = z.enum(["auto", "content", "session", "collaborative", "hybrid", "popular"]);
20
+ const contextType = z.enum([
21
+ "product_page", "listing_page", "category_page", "homepage", "cart", "account", "content_page", "custom",
22
+ ]);
23
+ const signalName = z.enum([
24
+ "current_item_id", "category_id", "collection_id", "user_id", "anonymous_id",
25
+ "session_id", "cart_item_ids", "locale", "custom_context",
26
+ ]);
27
+ const signals = z.object({
28
+ required: z.array(signalName).default([]).describe("Context keys this placement can't run without - missing one is a clear error, never a silent guess."),
29
+ optional: z.array(signalName).default([]).describe("Context keys this placement uses when present, but doesn't need."),
30
+ }).describe("Every context key is optional to send unless listed in `required`.");
31
+ const attributeRule = z.object({ attribute: z.string(), values: z.array(z.unknown()).min(1) });
32
+ const filters = z.object({
33
+ category_in: z.array(z.string()).optional(),
34
+ category_not_in: z.array(z.string()).optional(),
35
+ in_stock_only: z.boolean().optional().describe("Only filters items that actually have a stock/in_stock property - never excludes one for lacking it."),
36
+ include_attributes: z.array(attributeRule).optional(),
37
+ exclude_attributes: z.array(attributeRule).optional(),
38
+ });
39
+ const businessRules = z.object({
40
+ exclude_current_item: z.boolean().optional().describe("Default true - never recommend the item the visitor is already looking at back to itself."),
41
+ exclude_cart_items: z.boolean().optional().describe("Never recommend an item already in the cart - use for a cart cross-sell placement."),
42
+ });
43
+ const context = z.record(z.unknown()).describe("Sample context to run the placement against - any of current_item_id, category_id, collection_id, user_id, anonymous_id, session_id, cart_item_ids, locale, custom_context.");
44
+ server.tool("list_placements", "Every placement configured for this account. Check this before creating a new one, or to find a slug by name.", {}, async () => textResult(await client.listPlacements()));
45
+ server.tool("get_placement", "One placement's full configuration by slug.", { slug }, async ({ slug }) => textResult(await client.getPlacement(slug)));
46
+ server.tool("create_placement", "Registers a new named recommendation configuration - the abstraction to reach for instead of picking a raw strategy endpoint. Underneath it always calls one of LIKYLY's existing engines (content/session/collaborative/hybrid/popular) - 'auto' delegates to the same signal-based selection POST /getRec already uses (hybrid when both a user and an item are known, content from just an item, session from recent views, collaborative for a known user with a trained model, popular otherwise) - it is not a new ML model, just an orchestration choice. Set `signals.required` to the context keys this placement can't run without (e.g. current_item_id for a product-page placement) - everything else stays optional. `fallback_strategy` is tried if `strategy` returns nothing; 'popular' is always the final safety net regardless. Call preview_placement right after creating it.", {
47
+ slug,
48
+ name: z.string().min(1).max(200),
49
+ context_type: contextType.default("custom"),
50
+ product_type: z.string().optional().describe("Catalog namespace to recommend from - omit it when this account has a single catalog."),
51
+ limit: z.number().int().min(1).max(100).default(10),
52
+ audience: z.enum(["all", "anonymous", "identified"]).default("all"),
53
+ strategy: strategy.default("auto"),
54
+ signals: signals.default({ required: [], optional: [] }),
55
+ fallback_strategy: strategy.optional().describe("Tried if `strategy` returns no results. 'popular' is always the final safety net regardless."),
56
+ filters: filters.default({}),
57
+ business_rules: businessRules.default({}),
58
+ enabled: z.boolean().default(true),
59
+ }, async (input) => textResult(await client.createPlacement(input)));
60
+ server.tool("purge_recommendation_cache", "Empty the cache of anonymous recommendations: one placement's (`slug`) or every placement's (no slug). LIKYLY keeps an anonymous visitor's answer for a product for an hour and empties it on its own when the catalog or the placement changes - call this only when something changed that LIKYLY cannot see (stock managed outside LIKYLY, new product pages deployed) or when the developer asks to see fresh results now.", { slug: slug.optional() }, async ({ slug }) => textResult(await client.purgeRecommendationCache(slug)));
61
+ server.tool("update_placement", "Partial update of an existing placement - only the fields given are changed. Sending `signals`/`filters`/`business_rules` at all replaces that whole sub-object (not a deep merge). Use this to disable/re-enable too (`enabled: true|false`) - or delete_or_disable_placement's default mode, which is the same thing plus a clearer name for it.", {
62
+ slug,
63
+ name: z.string().optional(),
64
+ context_type: contextType.optional(),
65
+ product_type: z.string().optional(),
66
+ limit: z.number().int().min(1).max(100).optional(),
67
+ audience: z.enum(["all", "anonymous", "identified"]).optional(),
68
+ strategy: strategy.optional(),
69
+ signals: signals.optional(),
70
+ fallback_strategy: strategy.optional(),
71
+ filters: filters.optional(),
72
+ business_rules: businessRules.optional(),
73
+ enabled: z.boolean().optional(),
74
+ }, async ({ slug, ...input }) => textResult(await client.updatePlacement(slug, input)));
75
+ server.tool("delete_or_disable_placement", "mode:'disable' (the default, reversible): POST /placements/{slug}/recommend starts 404ing immediately, nothing is deleted - prefer this. mode:'delete': permanent, only for a configuration that was wrong and shouldn't exist at all.", { slug, mode: z.enum(["disable", "delete"]).default("disable") }, async ({ slug, mode }) => textResult(mode === "delete" ? await client.deletePlacement(slug) : await client.deactivatePlacement(slug)));
76
+ server.tool("get_placement_requirements", "Static introspection (no engine call) - the required/optional context signals, strategy, fallback_strategy and context_type. Call this before wiring a site to a placement, to know exactly what context (current_item_id, user_id, session_id, ...) to send at recommend time.", { slug }, async ({ slug }) => textResult(await client.getPlacementRequirements(slug)));
77
+ server.tool("preview_placement", "Runs a placement's full orchestration (strategy selection, fallback, filters) against a sample context - never writes anything, mints no recommendation_id, nothing attributable. Returns strategy_used, items, whether a fallback was used, which strategies were attempted, which context signals were actually present, and warnings/errors (a missing required signal shows up in `errors`, not a thrown error) - use this to validate a placement before wiring it into a site, or to debug one that isn't behaving as expected.", { slug, context: context.default({}), limit: z.number().int().min(1).max(100).optional() }, async ({ slug, context, limit }) => textResult(await client.previewPlacement(slug, context, limit)));
78
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { LikylyClient } from "./client.js";
3
+ export declare function createLikylyRecipeTools(server: McpServer, client: LikylyClient): void;
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ // get_integration_recipe: a structured contract for wiring a placement into a real codebase -
3
+ // packages to install, env vars, the public API to call, the context/events it needs, a short
4
+ // illustrative snippet, and warnings. Deliberately NOT a whole file to paste: `framework` /
5
+ // `renderingMode` / `hasAuthContext` are supplied by the calling agent (it already inspected
6
+ // the repo) - LIKYLY never tries to detect a customer's stack itself, and this tool composes
7
+ // its answer from that input plus the placement's own live requirements (get_placement_requirements),
8
+ // not from guessing.
9
+ export function createLikylyRecipeTools(server, client) {
10
+ function jsonResult(data) {
11
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
12
+ }
13
+ const framework = z.enum(["react", "nextjs", "vue", "svelte", "vanilla-js", "other"]);
14
+ server.tool("get_integration_recipe", "Everything needed to wire one placement into a real codebase, as structured data - not a file to copy-paste. Call this after create_placement (and, ideally, after preview_placement/configure_field_mapping have confirmed it produces sane results). Tell it the framework you found in the project (you inspected it - this tool never guesses), and it returns: packages to install, env vars, the public API to call for this framework, the context/events this specific placement needs, a short illustrative snippet, and warnings (e.g. the placement is disabled, or no auth context was given so userId will be omitted).", {
15
+ placement: z.string().min(1).max(128),
16
+ framework,
17
+ rendering_mode: z.enum(["csr", "ssr", "rsc"]).optional().describe("For react/nextjs: whether the integration point is a client component, a server-rendered page, or a React Server Component."),
18
+ has_auth_context: z.boolean().default(false).describe("Does the project already expose the current user's id at the point this placement will render?"),
19
+ }, async ({ placement, framework, rendering_mode, has_auth_context }) => {
20
+ const [requirements, tracking] = await Promise.all([
21
+ client.getPlacementRequirements(placement),
22
+ client.getTrackingRequirements(placement),
23
+ ]);
24
+ return jsonResult(buildRecipe(requirements, tracking, framework, rendering_mode, has_auth_context));
25
+ });
26
+ }
27
+ function buildRecipe(requirements, tracking, framework, renderingMode, hasAuthContext) {
28
+ const isReactFamily = framework === "react" || framework === "nextjs";
29
+ const packages = isReactFamily ? ["@likyly/sdk", "@likyly/react"] : ["@likyly/sdk"];
30
+ const env = [
31
+ {
32
+ name: framework === "nextjs" ? "NEXT_PUBLIC_LIKYLY_KEY" : framework === "vue" || framework === "svelte" ? "VITE_PUBLIC_LIKYLY_KEY" : "LIKYLY_PUBLIC_KEY",
33
+ value: "the account's restricted PUBLIC key only - never the secret or a developer key (see docs/placements.md's Security note)",
34
+ },
35
+ ];
36
+ const requiredContext = [
37
+ ...requirements.signals.required.map((name) => ({ name, required: true })),
38
+ ...requirements.signals.optional.map((name) => ({ name, required: false })),
39
+ ];
40
+ const requiredEvents = tracking.required_events.map((e) => ({ eventType: e.event_type, scope: e.scope, why: e.why }));
41
+ const publicApi = isReactFamily
42
+ ? "@likyly/react: <LikylyProvider apiKey> once at the app root, then useRecommendations({placement, context}) or <LikylyRecommendations placement .../> where the placement renders."
43
+ : "@likyly/sdk: new Likyly({apiKey}) once, then likyly.recommend({placement, context}) where the placement renders, and likyly.events.* for tracking.";
44
+ const minimalExample = isReactFamily
45
+ ? [
46
+ framework === "nextjs" ? '"use client";' : undefined,
47
+ `import { LikylyRecommendations } from "@likyly/react";`,
48
+ "",
49
+ `<LikylyRecommendations placement="${requirements.slug}"`,
50
+ requirements.signals.required.includes("current_item_id") || requirements.signals.optional.includes("current_item_id") ? " itemId={product.id}" : undefined,
51
+ requirements.signals.required.includes("user_id") || requirements.signals.optional.includes("user_id") ? " userId={user?.id}" : undefined,
52
+ "/>",
53
+ ]
54
+ .filter((line) => line !== undefined)
55
+ .join("\n")
56
+ : [
57
+ `import { Likyly } from "@likyly/sdk";`,
58
+ `const likyly = new Likyly({ apiKey: /* public key */ });`,
59
+ "",
60
+ `const result = await likyly.recommend({ placement: "${requirements.slug}", context: { itemId: product.id, userId: user?.id, sessionId } });`,
61
+ `// render result.items yourself, then:`,
62
+ `await likyly.events.recommendationImpression({ itemId: result.items[0].itemId, recommendationId: result.recommendationId, placement: "${requirements.slug}", userId: user?.id, sessionId });`,
63
+ ].join("\n");
64
+ const warnings = [];
65
+ if (!requirements.enabled) {
66
+ warnings.push(`Placement "${requirements.slug}" is currently disabled (enabled: false) - recommend() will 404 until it's re-enabled (update_placement / delete_or_disable_placement with mode "disable" toggles this).`);
67
+ }
68
+ if (!hasAuthContext && (requirements.signals.required.includes("user_id") || requirements.signals.optional.includes("user_id"))) {
69
+ warnings.push("No auth context given - userId will be omitted at the integration point until the project's own auth state is wired in; this placement will rely on session-based signals alone until then.");
70
+ }
71
+ if (isReactFamily && renderingMode === "rsc") {
72
+ warnings.push('useRecommendations/<LikylyRecommendations> use client-side React state (hooks) - render them from a Client Component ("use client"), not directly inside a React Server Component.');
73
+ }
74
+ if (framework === "vue" || framework === "svelte") {
75
+ warnings.push("No @likyly/vue or @likyly/svelte package exists yet - use @likyly/sdk's likyly.recommend()/likyly.events.* directly and wire the result into this framework's own reactivity (a computed/ref, a store, ...).");
76
+ }
77
+ if (requirements.fallback_strategy === null && requirements.strategy !== "auto" && requirements.strategy !== "popular") {
78
+ warnings.push(`This placement's strategy ("${requirements.strategy}") has no fallback_strategy configured - it still always falls back to "popular" as a last resort, but consider setting one explicitly for a more relevant fallback than pure popularity.`);
79
+ }
80
+ return { packages, env, publicApi, requiredContext, requiredEvents, minimalExample, warnings };
81
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { LikylyClient } from "./client.js";
3
+ export declare function createLikylyServer(client: LikylyClient, defaultProductType?: string): McpServer;
package/dist/tools.js ADDED
@@ -0,0 +1,75 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { createLikylyAdminTools } from "./admin-tools.js";
4
+ import { createLikylyPlacementTools } from "./placement-tools.js";
5
+ import { createLikylyRecipeTools } from "./recipe-tools.js";
6
+ import { createLikylyValidatorTools } from "./validator-tools.js";
7
+ // Shared tool surface: used by the local stdio server (index.ts) and the hosted HTTP
8
+ // service (http.ts). Two tiers of tools, both just forwarding whatever key this server was
9
+ // started with - the recommendation/event tools below work with the restricted public key;
10
+ // the admin tools (list_source_types..get_catalog_stats, in admin-tools.ts) configure and run
11
+ // catalog data-source integrations, and need the secret key or a developer key with the
12
+ // right scope instead (see /clients/me/developer-keys in the recsys API) - a caller using
13
+ // the wrong key gets a clear 401/403 back through the tool result, same as any other error
14
+ // here. Registering both unconditionally (rather than trying to detect the key's own
15
+ // capabilities up front) keeps this server simple and lets the API stay the single source of
16
+ // truth for what a given key can do.
17
+ export function createLikylyServer(client, defaultProductType) {
18
+ const server = new McpServer({ name: "likyly-recsys", version: "0.4.1" });
19
+ const productType = defaultProductType
20
+ ? z.string().optional().describe(`Catalog namespace. Defaults to "${defaultProductType}" if omitted.`)
21
+ : z.string().describe("Catalog namespace, e.g. \"movies\" or your own product category.");
22
+ function resolveProductType(value) {
23
+ const resolved = value ?? defaultProductType;
24
+ if (!resolved) {
25
+ throw new Error("No product_type given and LIKYLY_PRODUCT_TYPE is not set in the server's environment.");
26
+ }
27
+ return resolved;
28
+ }
29
+ function textResult(data) {
30
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
31
+ }
32
+ const id = z.string().min(1).max(255);
33
+ server.tool("get_recommendations", "The recommendation call to use: send whatever you know (a user_id, an anonymous session_id, the item_id being viewed, viewed_item_ids, a placement label) and LIKYLY picks the best strategy (hybrid, content, collaborative, session or popular) and returns it in `strategy`. Every response has a `recommendation_id` - pass it as recommendation_id to track_event for the impression/click/add_to_cart/purchase events that follow, to attribute them. Ids are YOUR own ids (any string).", {
34
+ product_type: productType,
35
+ user_id: id.optional(),
36
+ session_id: id.optional().describe("Anonymous visitor / browsing session id."),
37
+ item_id: id.optional().describe("The item being looked at."),
38
+ viewed_item_ids: z.array(id).max(50).optional().describe("Recently viewed item ids, oldest first."),
39
+ placement: z.string().max(128).optional().describe("Free-form label of where the recommendations are shown, e.g. homepage."),
40
+ count: z.number().int().min(1).max(100).default(10),
41
+ }, async ({ product_type, ...request }) => textResult(await client.getRecommendations(resolveProductType(product_type), request)));
42
+ server.tool("get_popular_recommendations", "Most popular products (pure popularity ranking) - the right fallback for a visitor with no browsing history and no product to anchor content similarity on.", { product_type: productType, count: z.number().int().min(1).max(50).default(10) }, async ({ product_type, count }) => textResult(await client.getPopularRecs(resolveProductType(product_type), count)));
43
+ server.tool("get_cold_start_recommendations", "Content-based recommendations similar to one product (synopsis/genre/description similarity + semantic embeddings) - works with zero user history, e.g. on a product page.", { product_type: productType, item_id: id, count: z.number().int().min(1).max(50).default(4) }, async ({ product_type, item_id, count }) => textResult(await client.getColdStartRecs(resolveProductType(product_type), item_id, count)));
44
+ server.tool("get_collaborative_recommendations", "Collaborative-filtering recommendations (implicit ALS) for a known/logged-in user, learned from cross-user purchase patterns.", { product_type: productType, user_id: id, count: z.number().int().min(1).max(50).default(3) }, async ({ product_type, user_id, count }) => textResult(await client.getUserRecs(resolveProductType(product_type), user_id, count)));
45
+ server.tool("get_hybrid_recommendations", "Blends content similarity to one product with collaborative filtering for one user. alpha (0-1) weights collaborative vs. content: 0 = pure content, 1 = pure collaborative, 0.5 = balanced.", {
46
+ product_type: productType,
47
+ user_id: id,
48
+ item_id: id,
49
+ count: z.number().int().min(1).max(50).default(3),
50
+ alpha: z.number().min(0).max(1).default(0.5),
51
+ }, async ({ product_type, user_id, item_id, count, alpha }) => textResult(await client.getHybridRecs(resolveProductType(product_type), user_id, item_id, count, alpha)));
52
+ server.tool("get_session_recommendations", "Recency-weighted recommendations from an explicit list of recently-viewed item ids - no account needed (anonymous session). The last id in the list is weighted the most.", { product_type: productType, viewed_item_ids: z.array(id).min(1).max(50), count: z.number().int().min(1).max(50).default(3) }, async ({ product_type, viewed_item_ids, count }) => textResult(await client.getSessionRecs(resolveProductType(product_type), viewed_item_ids, count)));
53
+ server.tool("get_user_session_recommendations", "Same recency-weighted logic as get_session_recommendations, but sourced from a logged-in user's persisted view history instead of a client-supplied list - survives across devices/sessions.", { product_type: productType, user_id: id, count: z.number().int().min(1).max(50).default(3) }, async ({ product_type, user_id, count }) => textResult(await client.getUserSessionRecs(resolveProductType(product_type), user_id, count)));
54
+ server.tool("track_event", "Record an interaction: impression, view, click, add_to_cart, remove_from_cart, purchase - or any event type of your own. Needs item_id and at least one of user_id (logged in) / session_id (anonymous). Pass the recommendation_id of the get_recommendations call that surfaced the item to attribute it. Send event_id (any unique string) on purchases so a retry is not counted twice.", {
55
+ product_type: productType,
56
+ event_type: z.string().regex(/^[a-zA-Z0-9_-]+$/).max(64).describe("e.g. view, click, purchase"),
57
+ item_id: id,
58
+ user_id: id.optional(),
59
+ session_id: id.optional(),
60
+ recommendation_id: z.string().max(128).optional(),
61
+ placement: z.string().max(128).optional(),
62
+ quantity: z.number().int().min(0).optional(),
63
+ properties: z.record(z.unknown()).optional().describe("Free-form JSON: price, currency, order_id, revenue, ..."),
64
+ event_id: z.string().max(255).optional(),
65
+ }, async ({ product_type, event_type, ...event }) => {
66
+ if (!event.user_id && !event.session_id)
67
+ throw new Error("Provide user_id and/or session_id.");
68
+ return textResult(await client.trackEvent(resolveProductType(product_type), event_type, event));
69
+ });
70
+ createLikylyAdminTools(server, client);
71
+ createLikylyPlacementTools(server, client);
72
+ createLikylyRecipeTools(server, client);
73
+ createLikylyValidatorTools(server, client);
74
+ return server;
75
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { LikylyClient } from "./client.js";
3
+ export declare function createLikylyValidatorTools(server: McpServer, client: LikylyClient): void;
@@ -0,0 +1,85 @@
1
+ import { z } from "zod";
2
+ // The integration validator: real facts from LIKYLY's own data (recent recommend calls,
3
+ // impressions, clicks, product views, add-to-carts, purchases), not a guess from reading the
4
+ // site's source. "Vérifie que mon intégration Likyly est correcte" should be answerable by
5
+ // calling validate_integration, not by Claude Code re-reading its own previous edits.
6
+ export function createLikylyValidatorTools(server, client) {
7
+ function textResult(text) {
8
+ return { content: [{ type: "text", text }] };
9
+ }
10
+ function jsonResult(data) {
11
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
12
+ }
13
+ const slug = z.string().min(1).max(128).describe("The placement's slug.");
14
+ const windowHours = z.number().int().min(1).max(24 * 30).default(24).describe("How far back to look, in hours. Default 24.");
15
+ server.tool("get_tracking_requirements", "Which events this placement needs instrumented, and why - call this before writing tracking code (recommendation_impression/recommendation_click are placement-specific; product_view/add_to_cart/purchase are catalog-wide funnel events every placement benefits from). Follow up with get_placement_health once the site is live to confirm they actually arrived.", { slug }, async ({ slug }) => jsonResult(await client.getTrackingRequirements(slug)));
16
+ server.tool("get_placement_health", "Structured counts of what actually arrived for this placement in the last window_hours (recommend calls, impressions, clicks, product views, add-to-carts, purchases), each marked ok/warning/missing. The raw data behind validate_integration - use that tool instead for a human-readable checklist; use this one if you need the numbers themselves.", { slug, window_hours: windowHours }, async ({ slug, window_hours }) => jsonResult(await client.getPlacementHealth(slug, window_hours)));
17
+ server.tool("get_recent_integration_events", "The individual recent recommend calls and tracked events behind get_placement_health's counts - for debugging exactly which item/session/strategy was involved, when the aggregate counts alone don't explain what's wrong (e.g. recommendations were requested but always for the wrong item_id).", { slug, window_hours: windowHours, limit: z.number().int().min(1).max(100).default(20) }, async ({ slug, window_hours, limit }) => jsonResult(await client.getRecentIntegrationEvents(slug, window_hours, limit)));
18
+ server.tool("get_model_overview", "Read-only report on one catalog's recommendation model - what /likyly-model shows. `versions`: every trained version of the collaborative (ALS) model, newest first, with trained_at, triggered_by (manual or auto), precision_at_k (k = precision_k, measured on a held-out 20% of interactions), and the users/items/interactions it learned from; `is_active` marks the one serving production, the others are history or candidates rejected for scoring lower. `performance`: per engine that actually served this catalog in the last `days` (hybrid, content, collaborative, session, popular - the strategy after any fallback), recommendations served and the impressions, clicks, add-to-carts and purchases attributed to them, with ctr. Only collaborative and hybrid use the trained model; content uses item similarity, session the visitor's recent views, popular the catalog's popularity - none of those three is versioned. Get the catalog name from list_placements (each placement's product_type).", {
19
+ product_type: z.string().min(1).max(64).describe("The catalog, e.g. a placement's product_type."),
20
+ days: z.number().int().min(1).max(90).default(7).describe("How many days of production traffic to count. Default 7."),
21
+ }, async ({ product_type, days }) => jsonResult(await client.getModelOverview(product_type, days)));
22
+ server.tool("validate_integration", "Step 11 of wiring up a placement: verifies the integration is actually working, from LIKYLY's own data - not a guess from reading the site's source. Call this after rendering the placement and adding tracking, whenever a developer asks to \"check\"/\"verify\" their LIKYLY integration. Renders a checklist (✓ ok, ⚠ received but worth a look, ✗ missing/critical) and, for anything not ok, a concrete next step - loop back to get_integration_recipe or get_tracking_requirements for the ones it names.", { slug, window_hours: windowHours }, async ({ slug, window_hours }) => textResult(await renderValidation(client, slug, window_hours)));
23
+ }
24
+ const CHECK_LABELS = {
25
+ recommendations_requested: {
26
+ ok: "recommendations requested",
27
+ bad: "recommendations not requested yet",
28
+ nextStep: "Call POST /placements/{slug}/recommend (likyly.recommend(...) in the SDK) from the page - nothing else here can be true until this is.",
29
+ },
30
+ current_item_supplied: {
31
+ ok: "current item supplied",
32
+ bad: "current item not supplied",
33
+ nextStep: "Pass context.itemId (the product/article being viewed) to likyly.recommend / useRecommendations - this placement requires it.",
34
+ },
35
+ anonymous_session_supplied: {
36
+ ok: "anonymous session supplied",
37
+ bad: "anonymous session not supplied",
38
+ nextStep: "Pass context.sessionId (or anonymousId - see LikylySession) for anonymous visitors - this placement requires one.",
39
+ },
40
+ impressions_received: {
41
+ ok: "impressions received",
42
+ bad: "impressions not received",
43
+ nextStep: "Fire recommendationImpression for each rendered item (automatic with <LikylyRecommendations>; call trackImpression yourself with useRecommendations).",
44
+ },
45
+ recommendation_clicks_received: {
46
+ ok: "recommendation clicks received",
47
+ bad: "recommendation clicks not received",
48
+ nextStep: "Fire recommendationClick when a recommended item is clicked (automatic with <LikylyRecommendations>; call trackClick yourself with useRecommendations). Can legitimately be zero on low traffic.",
49
+ },
50
+ product_views_received: {
51
+ ok: "product views received",
52
+ bad: "product views not received",
53
+ nextStep: "Call likyly.events.productView on your product/article page - this is a catalog-wide event, not specific to this placement.",
54
+ },
55
+ add_to_cart_received: {
56
+ ok: "add_to_cart received",
57
+ bad: "add_to_cart not received",
58
+ nextStep: "Call likyly.events.addToCart where the visitor adds an item to their cart - optional but improves ranking quality over time.",
59
+ },
60
+ purchases_received: {
61
+ ok: "purchases received",
62
+ bad: "purchases not received",
63
+ nextStep: "Call likyly.events.purchase (with an eventId, so a retry never double-counts a sale) once checkout completes - the strongest training signal, and what attribution ultimately measures.",
64
+ },
65
+ };
66
+ const SYMBOL = { ok: "✓", warning: "⚠", missing: "✗" };
67
+ async function renderValidation(client, slug, windowHours) {
68
+ const health = (await client.getPlacementHealth(slug, windowHours));
69
+ const lines = [`Placement: ${slug}`, ""];
70
+ const nextSteps = [];
71
+ for (const check of health.checks) {
72
+ const labels = CHECK_LABELS[check.name] ?? { ok: check.name, bad: `${check.name} (not received)`, nextStep: "" };
73
+ const symbol = SYMBOL[check.status] ?? "?";
74
+ lines.push(`${symbol} ${check.status === "ok" ? labels.ok : labels.bad}`);
75
+ if (check.status !== "ok" && labels.nextStep)
76
+ nextSteps.push(`- ${labels.bad}: ${labels.nextStep}`);
77
+ }
78
+ if (nextSteps.length > 0) {
79
+ lines.push("", "Next steps:", ...nextSteps);
80
+ }
81
+ else {
82
+ lines.push("", "Everything this validator checks is flowing - the integration looks correct.");
83
+ }
84
+ return lines.join("\n");
85
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@likyly/mcp-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.1",
4
+ "description": "Lets your AI coding assistant (Claude Code, Cursor, VS Code, Codex) set up LIKYLY product recommendations: connect your catalog, create placements, integrate and verify.",
5
+ "keywords": [
6
+ "likyly",
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "mcp-server",
10
+ "claude-code",
11
+ "cursor",
12
+ "recommendations",
13
+ "ecommerce"
14
+ ],
15
+ "homepage": "https://www.likyly.com",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Likyly/likyly-recsys.git",
19
+ "directory": "sdk/mcp"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/Likyly/likyly-recsys/issues"
23
+ },
24
+ "author": "LIKYLY (https://www.likyly.com)",
25
+ "type": "module",
26
+ "bin": {
27
+ "likyly-mcp-server": "./dist/index.js",
28
+ "likyly-mcp-http": "./dist/http.js"
29
+ },
30
+ "main": "./dist/index.js",
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "start": "node dist/index.js",
37
+ "start:http": "node dist/http.js"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.12.0",
44
+ "zod": "^3.24.1"
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^5.5.4",
48
+ "@types/node": "^20"
49
+ },
50
+ "license": "MIT"
51
+ }