@sellhapi/mcp 0.0.0-stage → 0.1.2
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 +147 -2
- package/dist/client.d.ts +33 -0
- package/dist/client.js +99 -0
- package/dist/hosted.d.ts +25 -0
- package/dist/hosted.js +241 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +54 -0
- package/dist/resources/build-rules.d.ts +5 -0
- package/dist/resources/build-rules.js +157 -0
- package/dist/resources/capability-patterns.d.ts +15 -0
- package/dist/resources/capability-patterns.js +366 -0
- package/dist/resources/design-direction-schema.d.ts +11 -0
- package/dist/resources/design-direction-schema.js +252 -0
- package/dist/resources/index.d.ts +9 -0
- package/dist/resources/index.js +55 -0
- package/dist/resources/runtime-hooks.d.ts +5 -0
- package/dist/resources/runtime-hooks.js +290 -0
- package/dist/resources/wireframe-schema.d.ts +8 -0
- package/dist/resources/wireframe-schema.js +220 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +27 -0
- package/dist/tools/commerce.d.ts +9 -0
- package/dist/tools/commerce.js +86 -0
- package/dist/tools/content.d.ts +8 -0
- package/dist/tools/content.js +49 -0
- package/dist/tools/intelligence.d.ts +5 -0
- package/dist/tools/intelligence.js +39 -0
- package/dist/tools/studio.d.ts +5 -0
- package/dist/tools/studio.js +342 -0
- package/dist/util.d.ts +16 -0
- package/dist/util.js +21 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sellhapi/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for SellHapi — build and manage storefronts from AI tools.
|
|
4
|
+
|
|
5
|
+
## Two modes
|
|
6
|
+
|
|
7
|
+
**Stdio** (Claude Code, Cursor, Windsurf) — runs locally, authenticates with API key.
|
|
8
|
+
|
|
9
|
+
**Hosted HTTP** (ChatGPT, Claude web) — runs as a service, authenticates via OAuth 2.1.
|
|
10
|
+
|
|
11
|
+
## Setup — stdio
|
|
12
|
+
|
|
13
|
+
### 1. Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g @sellhapi/mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 2. Get an API key
|
|
20
|
+
|
|
21
|
+
Go to **sellhapi.com** → Settings → API Keys → Create Key.
|
|
22
|
+
|
|
23
|
+
### 3. Configure your AI tool
|
|
24
|
+
|
|
25
|
+
**Claude Code** — add to `~/.claude/claude_desktop_config.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"sellhapi": {
|
|
31
|
+
"command": "sellhapi-mcp",
|
|
32
|
+
"args": ["--api-key", "sk_live_your_key_here"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Cursor** — add to MCP settings:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"sellhapi": {
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["-y", "@sellhapi/mcp@latest", "--api-key", "sk_live_your_key_here"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or set the environment variable instead of `--api-key`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export SELLHAPI_API_KEY=sk_live_your_key_here
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Setup — hosted (ChatGPT, Claude web)
|
|
58
|
+
|
|
59
|
+
The SellHapi MCP server is hosted at:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
https://sellhapi-mcp.onrender.com/mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
No installation required — just connect your AI client.
|
|
66
|
+
|
|
67
|
+
### Claude web
|
|
68
|
+
|
|
69
|
+
1. Go to **claude.ai** → Settings → **Integrations** → Add integration
|
|
70
|
+
2. Enter: `https://sellhapi-mcp.onrender.com/mcp`
|
|
71
|
+
3. Click Connect — you'll be redirected to SellHapi to authorise access
|
|
72
|
+
4. Once authorised, all SellHapi tools are available in your Claude conversations
|
|
73
|
+
|
|
74
|
+
### ChatGPT
|
|
75
|
+
|
|
76
|
+
1. Go to **chatgpt.com** → your profile → **My GPTs** → Create a GPT (or edit an existing one)
|
|
77
|
+
2. In the GPT editor → **Configure** → **Actions** → Add action
|
|
78
|
+
3. Set the server URL to: `https://sellhapi-mcp.onrender.com/mcp`
|
|
79
|
+
4. Authentication: OAuth 2.0 — the server handles the full flow automatically
|
|
80
|
+
|
|
81
|
+
### sell_api env vars
|
|
82
|
+
|
|
83
|
+
The sell_api server needs these for OAuth:
|
|
84
|
+
|
|
85
|
+
| Variable | Description |
|
|
86
|
+
|----------|-------------|
|
|
87
|
+
| `MCP_OAUTH_SECRET` | JWT signing secret for MCP access tokens. Falls back to `JWT_VENDOR_SECRET` if not set. |
|
|
88
|
+
| `SELLHAPI_VENDOR_URL` | Base URL for consent pages (default: `https://sellhapi.com`) |
|
|
89
|
+
|
|
90
|
+
## Tools
|
|
91
|
+
|
|
92
|
+
### Studio (15)
|
|
93
|
+
- `list_projects` — list vendor's Studio projects
|
|
94
|
+
- `get_project` — get project details with code
|
|
95
|
+
- `create_project` — create a new project (returns workflow guide + build rules)
|
|
96
|
+
- `apply_design_direction` — store a full DesignDirection (colors, fonts, pages) — zero credits
|
|
97
|
+
- `set_design_direction` — ask SellHapi's Claude to generate a direction from a brief (costs credits)
|
|
98
|
+
- `analyze_design_reference` — extract design DNA from an image, website URL, or Pinterest pins
|
|
99
|
+
- `import_website_content` — scrape existing website copy into the project
|
|
100
|
+
- `analyze_reference` — fetch OG metadata + theme color from any URL
|
|
101
|
+
- `apply_wireframe` — store approved mid-fidelity wireframes per page — zero credits
|
|
102
|
+
- `push_code` — push React code (validated by BuildGuard) — zero credits
|
|
103
|
+
- `patch_code` — push only changed files, merged into existing code — zero credits
|
|
104
|
+
- `deploy_website` — publish project to live storefront
|
|
105
|
+
- `get_preview` — get current sandbox preview URL
|
|
106
|
+
- `list_revisions` — list revision history
|
|
107
|
+
- `get_revision` — get a specific revision's code
|
|
108
|
+
- `restore_revision` — restore project to a previous revision
|
|
109
|
+
|
|
110
|
+
### Context
|
|
111
|
+
- `get_vendor_context` — call this first — returns capabilities, store slug, product/service counts in one call
|
|
112
|
+
- `suggest_capabilities` — given a business brief, returns ranked capability IDs to activate
|
|
113
|
+
|
|
114
|
+
### Commerce (8)
|
|
115
|
+
- `list_products`, `list_digital_products`, `list_service_listings`
|
|
116
|
+
- `list_events`, `list_membership_tiers`, `list_donation_campaigns`
|
|
117
|
+
- `list_appointment_services`, `list_capabilities`
|
|
118
|
+
|
|
119
|
+
### Content (4)
|
|
120
|
+
- `list_content`, `list_forms`, `list_collections`, `list_articles`
|
|
121
|
+
|
|
122
|
+
### Intelligence (2)
|
|
123
|
+
- `list_orders`, `get_wallet_balance`
|
|
124
|
+
|
|
125
|
+
### Reference
|
|
126
|
+
- `get_build_rules` — full BuildGuard validation rules (call when push_code fails)
|
|
127
|
+
- `get_runtime_hooks` — complete @sellhapi/studio-runtime hook reference
|
|
128
|
+
|
|
129
|
+
## Resources
|
|
130
|
+
|
|
131
|
+
- `sellhapi://runtime-hooks` — available React hooks from @sellhapi/studio-runtime
|
|
132
|
+
- `sellhapi://build-rules` — BuildGuard validation rules
|
|
133
|
+
- `sellhapi://capability-patterns` — UX patterns per capability
|
|
134
|
+
|
|
135
|
+
## Auth
|
|
136
|
+
|
|
137
|
+
**API key** (`sk_live_...`) — for stdio clients. Create at sellhapi.com → Settings → API Keys.
|
|
138
|
+
|
|
139
|
+
**OAuth 2.1** — for web clients. The hosted server proxies OAuth to sell_api. Supports dynamic client registration (RFC 7591) and PKCE (S256).
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm run dev # watch mode (tsc --watch)
|
|
145
|
+
npm run build # compile
|
|
146
|
+
npm start # stdio mode
|
|
147
|
+
npm run start:hosted # hosted HTTP mode
|
|
148
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for sell_api.
|
|
3
|
+
* All MCP tools delegate to this client for actual API calls.
|
|
4
|
+
*
|
|
5
|
+
* Supports two modes:
|
|
6
|
+
* 1. Module-level config (stdio) — single API key set once via configure()
|
|
7
|
+
* 2. Per-request context (hosted) — API key scoped via AsyncLocalStorage
|
|
8
|
+
*
|
|
9
|
+
* In hosted mode, withApiKey() wraps each request handler to scope the
|
|
10
|
+
* credentials. Tool code is identical in both modes — get/post/etc. resolve
|
|
11
|
+
* the active key automatically.
|
|
12
|
+
*/
|
|
13
|
+
/** Set module-level credentials (stdio mode). */
|
|
14
|
+
export declare function configure(key: string, base?: string): void;
|
|
15
|
+
/**
|
|
16
|
+
* Run a callback with scoped API key credentials (hosted mode).
|
|
17
|
+
* All get/post/etc. calls inside the callback will use these credentials.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withApiKey<T>(apiKey: string, baseUrl: string | undefined, fn: () => T): T;
|
|
20
|
+
/** Error with full response body preserved for structured error handling. */
|
|
21
|
+
export declare class ApiError extends Error {
|
|
22
|
+
readonly status: number;
|
|
23
|
+
readonly body: any;
|
|
24
|
+
constructor(message: string, status: number, body: any);
|
|
25
|
+
}
|
|
26
|
+
export declare function apiCall<T = unknown>(method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown): Promise<T>;
|
|
27
|
+
/**
|
|
28
|
+
* Convenience wrappers.
|
|
29
|
+
*/
|
|
30
|
+
export declare const get: <T = unknown>(path: string) => Promise<T>;
|
|
31
|
+
export declare const post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
|
|
32
|
+
export declare const patch: <T = unknown>(path: string, body?: unknown) => Promise<T>;
|
|
33
|
+
export declare const del: <T = unknown>(path: string) => Promise<T>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP client for sell_api.
|
|
4
|
+
* All MCP tools delegate to this client for actual API calls.
|
|
5
|
+
*
|
|
6
|
+
* Supports two modes:
|
|
7
|
+
* 1. Module-level config (stdio) — single API key set once via configure()
|
|
8
|
+
* 2. Per-request context (hosted) — API key scoped via AsyncLocalStorage
|
|
9
|
+
*
|
|
10
|
+
* In hosted mode, withApiKey() wraps each request handler to scope the
|
|
11
|
+
* credentials. Tool code is identical in both modes — get/post/etc. resolve
|
|
12
|
+
* the active key automatically.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.del = exports.patch = exports.post = exports.get = exports.ApiError = void 0;
|
|
16
|
+
exports.configure = configure;
|
|
17
|
+
exports.withApiKey = withApiKey;
|
|
18
|
+
exports.apiCall = apiCall;
|
|
19
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
20
|
+
const DEFAULT_BASE_URL = "https://sell-api-xl2a.onrender.com";
|
|
21
|
+
/** AsyncLocalStorage for per-request API key scoping in hosted mode. */
|
|
22
|
+
const als = new node_async_hooks_1.AsyncLocalStorage();
|
|
23
|
+
/** Module-level fallback for stdio mode (single key). */
|
|
24
|
+
let globalCtx;
|
|
25
|
+
/** Set module-level credentials (stdio mode). */
|
|
26
|
+
function configure(key, base) {
|
|
27
|
+
globalCtx = {
|
|
28
|
+
apiKey: key,
|
|
29
|
+
baseUrl: (base ?? DEFAULT_BASE_URL).replace(/\/$/, ""),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run a callback with scoped API key credentials (hosted mode).
|
|
34
|
+
* All get/post/etc. calls inside the callback will use these credentials.
|
|
35
|
+
*/
|
|
36
|
+
function withApiKey(apiKey, baseUrl, fn) {
|
|
37
|
+
const ctx = {
|
|
38
|
+
apiKey,
|
|
39
|
+
baseUrl: (baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""),
|
|
40
|
+
};
|
|
41
|
+
return als.run(ctx, fn);
|
|
42
|
+
}
|
|
43
|
+
/** Resolve the active credentials (ALS first, then module-level). */
|
|
44
|
+
function getContext() {
|
|
45
|
+
const ctx = als.getStore() ?? globalCtx;
|
|
46
|
+
if (!ctx)
|
|
47
|
+
throw new Error("Client not configured — call configure() or use withApiKey()");
|
|
48
|
+
return ctx;
|
|
49
|
+
}
|
|
50
|
+
// ── API calls ────────────────────────────────────────────────────────────────
|
|
51
|
+
/** Error with full response body preserved for structured error handling. */
|
|
52
|
+
class ApiError extends Error {
|
|
53
|
+
status;
|
|
54
|
+
body;
|
|
55
|
+
constructor(message, status, body) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.status = status;
|
|
58
|
+
this.body = body;
|
|
59
|
+
this.name = "ApiError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.ApiError = ApiError;
|
|
63
|
+
async function apiCall(method, path, body) {
|
|
64
|
+
const { apiKey, baseUrl } = getContext();
|
|
65
|
+
const url = `${baseUrl}${path}`;
|
|
66
|
+
const headers = {
|
|
67
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
};
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
method,
|
|
72
|
+
headers,
|
|
73
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
74
|
+
});
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
data = { raw: text };
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const msg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
|
|
85
|
+
throw new ApiError(msg, res.status, data);
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Convenience wrappers.
|
|
91
|
+
*/
|
|
92
|
+
const get = (path) => apiCall("GET", path);
|
|
93
|
+
exports.get = get;
|
|
94
|
+
const post = (path, body) => apiCall("POST", path, body);
|
|
95
|
+
exports.post = post;
|
|
96
|
+
const patch = (path, body) => apiCall("PATCH", path, body);
|
|
97
|
+
exports.patch = patch;
|
|
98
|
+
const del = (path) => apiCall("DELETE", path);
|
|
99
|
+
exports.del = del;
|
package/dist/hosted.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @sellhapi/mcp — hosted HTTP entry point with OAuth 2.1.
|
|
4
|
+
*
|
|
5
|
+
* For web-based AI clients (ChatGPT, Claude web) that connect via Streamable HTTP.
|
|
6
|
+
* Supports two auth methods:
|
|
7
|
+
* 1. API key (Bearer sk_live_...) — directly scoped
|
|
8
|
+
* 2. OAuth (Bearer eyJ...) — user logs in via SellHapi, AI client gets token
|
|
9
|
+
*
|
|
10
|
+
* The OAuth flow uses the MCP SDK's ProxyOAuthServerProvider which delegates
|
|
11
|
+
* all auth decisions to sell_api's OAuth endpoints. This server is stateless
|
|
12
|
+
* with respect to auth — it just proxies.
|
|
13
|
+
*
|
|
14
|
+
* Environment variables:
|
|
15
|
+
* PORT — HTTP port (default 3100)
|
|
16
|
+
* SELLHAPI_API_URL — sell_api base URL (default production)
|
|
17
|
+
* MCP_SERVER_URL — public URL of this MCP server (for OAuth metadata)
|
|
18
|
+
*
|
|
19
|
+
* Endpoints:
|
|
20
|
+
* POST/GET/DELETE /mcp — MCP Streamable HTTP
|
|
21
|
+
* GET /health — Health check
|
|
22
|
+
* /.well-known/* — OAuth metadata (auto-mounted by mcpAuthRouter)
|
|
23
|
+
* /authorize, /token, /register — OAuth (proxied to sell_api)
|
|
24
|
+
*/
|
|
25
|
+
export {};
|
package/dist/hosted.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* @sellhapi/mcp — hosted HTTP entry point with OAuth 2.1.
|
|
5
|
+
*
|
|
6
|
+
* For web-based AI clients (ChatGPT, Claude web) that connect via Streamable HTTP.
|
|
7
|
+
* Supports two auth methods:
|
|
8
|
+
* 1. API key (Bearer sk_live_...) — directly scoped
|
|
9
|
+
* 2. OAuth (Bearer eyJ...) — user logs in via SellHapi, AI client gets token
|
|
10
|
+
*
|
|
11
|
+
* The OAuth flow uses the MCP SDK's ProxyOAuthServerProvider which delegates
|
|
12
|
+
* all auth decisions to sell_api's OAuth endpoints. This server is stateless
|
|
13
|
+
* with respect to auth — it just proxies.
|
|
14
|
+
*
|
|
15
|
+
* Environment variables:
|
|
16
|
+
* PORT — HTTP port (default 3100)
|
|
17
|
+
* SELLHAPI_API_URL — sell_api base URL (default production)
|
|
18
|
+
* MCP_SERVER_URL — public URL of this MCP server (for OAuth metadata)
|
|
19
|
+
*
|
|
20
|
+
* Endpoints:
|
|
21
|
+
* POST/GET/DELETE /mcp — MCP Streamable HTTP
|
|
22
|
+
* GET /health — Health check
|
|
23
|
+
* /.well-known/* — OAuth metadata (auto-mounted by mcpAuthRouter)
|
|
24
|
+
* /authorize, /token, /register — OAuth (proxied to sell_api)
|
|
25
|
+
*/
|
|
26
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
27
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
28
|
+
};
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
const express_1 = __importDefault(require("express"));
|
|
31
|
+
const node_crypto_1 = require("node:crypto");
|
|
32
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
33
|
+
const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
|
|
34
|
+
const proxyProvider_js_1 = require("@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js");
|
|
35
|
+
const client_js_1 = require("./client.js");
|
|
36
|
+
const server_js_1 = require("./server.js");
|
|
37
|
+
const PORT = parseInt(process.env.PORT ?? "3100", 10);
|
|
38
|
+
const API_URL = process.env.SELLHAPI_API_URL ?? "https://sell-api-xl2a.onrender.com";
|
|
39
|
+
const SERVER_URL = process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}`;
|
|
40
|
+
const sessions = new Map();
|
|
41
|
+
const SESSION_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours — long enough for a full AI conversation
|
|
42
|
+
const MAX_SESSIONS = 1000;
|
|
43
|
+
setInterval(() => {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
for (const [id, session] of sessions) {
|
|
46
|
+
if (now - session.lastUsed > SESSION_TTL_MS) {
|
|
47
|
+
session.transport.close?.();
|
|
48
|
+
sessions.delete(id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}, 5 * 60 * 1000).unref();
|
|
52
|
+
// ── OAuth provider (proxies to sell_api) ─────────────────────────────────────
|
|
53
|
+
const oauthProvider = new proxyProvider_js_1.ProxyOAuthServerProvider({
|
|
54
|
+
endpoints: {
|
|
55
|
+
authorizationUrl: `${API_URL}/api/mcp/oauth/authorize`,
|
|
56
|
+
tokenUrl: `${API_URL}/api/mcp/oauth/token`,
|
|
57
|
+
registrationUrl: `${API_URL}/api/mcp/oauth/register`,
|
|
58
|
+
},
|
|
59
|
+
async verifyAccessToken(token) {
|
|
60
|
+
const res = await fetch(`${API_URL}/api/mcp/oauth/verify-token`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ token }),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new Error("Invalid or expired token");
|
|
67
|
+
}
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (!data.vendorId) {
|
|
70
|
+
throw new Error("Invalid token — missing vendorId");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
token,
|
|
74
|
+
clientId: data.clientId,
|
|
75
|
+
scopes: data.scope ? data.scope.split(" ") : [],
|
|
76
|
+
expiresAt: data.expiresAt,
|
|
77
|
+
extra: { vendorId: data.vendorId },
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
async getClient(clientId) {
|
|
81
|
+
const res = await fetch(`${API_URL}/api/mcp/oauth/client/${encodeURIComponent(clientId)}`);
|
|
82
|
+
if (!res.ok)
|
|
83
|
+
return undefined;
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
return data;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
// ── Resolve API key from auth context ────────────────────────────────────────
|
|
89
|
+
function resolveApiKeyFromAuth(req) {
|
|
90
|
+
const auth = req.headers.authorization;
|
|
91
|
+
if (!auth)
|
|
92
|
+
return null;
|
|
93
|
+
const token = auth.replace(/^Bearer\s+/i, "");
|
|
94
|
+
// API key path — pass through directly
|
|
95
|
+
if (token.startsWith("sk_live_")) {
|
|
96
|
+
return token;
|
|
97
|
+
}
|
|
98
|
+
// OAuth path — the authInfo was set by mcpAuthRouter's bearer middleware
|
|
99
|
+
// We use the token itself as the "API key" for the HTTP client
|
|
100
|
+
// The sell_api apiKeyAuth middleware accepts both sk_live_ and JWT tokens
|
|
101
|
+
return token;
|
|
102
|
+
}
|
|
103
|
+
// ── Express app ──────────────────────────────────────────────────────────────
|
|
104
|
+
const app = (0, express_1.default)();
|
|
105
|
+
app.set("trust proxy", 1); // Render runs behind a reverse proxy
|
|
106
|
+
// CORS
|
|
107
|
+
app.use((req, res, next) => {
|
|
108
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
109
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
110
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
|
|
111
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
112
|
+
if (req.method === "OPTIONS") {
|
|
113
|
+
res.sendStatus(204);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
next();
|
|
117
|
+
});
|
|
118
|
+
// Health check
|
|
119
|
+
app.get("/health", (_req, res) => {
|
|
120
|
+
res.json({ status: "ok", sessions: sessions.size });
|
|
121
|
+
});
|
|
122
|
+
// OAuth routes (metadata, authorize, token, register)
|
|
123
|
+
// Must be mounted at root for .well-known discovery to work
|
|
124
|
+
app.use((0, router_js_1.mcpAuthRouter)({
|
|
125
|
+
provider: oauthProvider,
|
|
126
|
+
issuerUrl: new URL(SERVER_URL),
|
|
127
|
+
scopesSupported: ["studio", "commerce", "content", "intelligence"],
|
|
128
|
+
serviceDocumentationUrl: new URL("https://sellhapi.com/mcp"),
|
|
129
|
+
}));
|
|
130
|
+
// MCP endpoint — JSON body parsing (10mb to handle large push_code payloads)
|
|
131
|
+
app.use("/mcp", express_1.default.json({ limit: "10mb" }));
|
|
132
|
+
// MCP POST — initialize or send message
|
|
133
|
+
app.post("/mcp", async (req, res) => {
|
|
134
|
+
const apiKey = resolveApiKeyFromAuth(req);
|
|
135
|
+
if (!apiKey) {
|
|
136
|
+
res.status(401).json({ error: "Missing authorization" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parsedBody = req.body;
|
|
140
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
141
|
+
const isInit = Array.isArray(parsedBody)
|
|
142
|
+
? parsedBody.some((m) => m.method === "initialize")
|
|
143
|
+
: parsedBody?.method === "initialize";
|
|
144
|
+
if (isInit) {
|
|
145
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
146
|
+
res.status(429).json({ error: "Too many active sessions. Try again later." });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const server = (0, server_js_1.createMcpServer)();
|
|
150
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
151
|
+
sessionIdGenerator: () => (0, node_crypto_1.randomUUID)(),
|
|
152
|
+
onsessioninitialized: (sid) => {
|
|
153
|
+
console.log(`[mcp] session initialized: ${sid}`);
|
|
154
|
+
sessions.set(sid, { server, transport, apiKey, lastUsed: Date.now() });
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
transport.onclose = () => {
|
|
158
|
+
if (transport.sessionId)
|
|
159
|
+
sessions.delete(transport.sessionId);
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
await server.connect(transport);
|
|
163
|
+
await (0, client_js_1.withApiKey)(apiKey, API_URL, () => transport.handleRequest(req, res, parsedBody));
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
console.error("[mcp] init error:", err);
|
|
167
|
+
if (!res.headersSent)
|
|
168
|
+
res.status(500).json({ error: "Internal server error" });
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
173
|
+
console.log(`[mcp] unknown session: ${sessionId}, active sessions: ${sessions.size}`);
|
|
174
|
+
res.status(400).json({ error: "Missing or invalid Mcp-Session-Id. Initialize first." });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const session = sessions.get(sessionId);
|
|
178
|
+
session.lastUsed = Date.now();
|
|
179
|
+
const method = Array.isArray(parsedBody)
|
|
180
|
+
? parsedBody.map((m) => m.method).join(",")
|
|
181
|
+
: parsedBody?.method;
|
|
182
|
+
console.log(`[mcp] ${sessionId.slice(0, 8)}… method=${method}`);
|
|
183
|
+
try {
|
|
184
|
+
await (0, client_js_1.withApiKey)(session.apiKey, API_URL, () => session.transport.handleRequest(req, res, parsedBody));
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.error(`[mcp] ${sessionId.slice(0, 8)}… error:`, err);
|
|
188
|
+
if (!res.headersSent)
|
|
189
|
+
res.status(500).json({ error: "Internal server error" });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// MCP GET — SSE stream
|
|
193
|
+
app.get("/mcp", async (req, res) => {
|
|
194
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
195
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
196
|
+
res.status(400).json({ error: "Missing or invalid Mcp-Session-Id." });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const session = sessions.get(sessionId);
|
|
200
|
+
session.lastUsed = Date.now();
|
|
201
|
+
try {
|
|
202
|
+
await (0, client_js_1.withApiKey)(session.apiKey, API_URL, () => session.transport.handleRequest(req, res));
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
console.error(`[mcp] GET stream error:`, err);
|
|
206
|
+
if (!res.headersSent)
|
|
207
|
+
res.status(500).json({ error: "Internal server error" });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// MCP DELETE — session teardown
|
|
211
|
+
app.delete("/mcp", (req, res) => {
|
|
212
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
213
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
214
|
+
const session = sessions.get(sessionId);
|
|
215
|
+
session.transport.close?.();
|
|
216
|
+
sessions.delete(sessionId);
|
|
217
|
+
}
|
|
218
|
+
res.sendStatus(204);
|
|
219
|
+
});
|
|
220
|
+
// ── Keep-alive ping (prevent Render free tier sleep) ────────────────────────
|
|
221
|
+
// Ping both sell_api and this MCP server every 10 minutes.
|
|
222
|
+
// sell_api: keeps it warm for tool calls.
|
|
223
|
+
// MCP server (self): prevents Render from sleeping the process, which would
|
|
224
|
+
// wipe the in-memory sessions Map and force all clients to reinitialize.
|
|
225
|
+
setInterval(async () => {
|
|
226
|
+
try {
|
|
227
|
+
await fetch(API_URL);
|
|
228
|
+
}
|
|
229
|
+
catch { /* ignore */ }
|
|
230
|
+
try {
|
|
231
|
+
await fetch(`${SERVER_URL}/health`);
|
|
232
|
+
}
|
|
233
|
+
catch { /* ignore */ }
|
|
234
|
+
}, 10 * 60 * 1000).unref();
|
|
235
|
+
// ── Start ────────────────────────────────────────────────────────────────────
|
|
236
|
+
app.listen(PORT, () => {
|
|
237
|
+
console.log(`SellHapi MCP server (hosted + OAuth) listening on port ${PORT}`);
|
|
238
|
+
console.log(` MCP endpoint: ${SERVER_URL}/mcp`);
|
|
239
|
+
console.log(` OAuth issuer: ${SERVER_URL}`);
|
|
240
|
+
console.log(` Health check: ${SERVER_URL}/health`);
|
|
241
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @sellhapi/mcp — stdio entry point.
|
|
4
|
+
*
|
|
5
|
+
* For local AI tools (Claude Code, Cursor, etc.) that connect via stdio.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* npx @sellhapi/mcp --api-key sk_live_...
|
|
9
|
+
* npx @sellhapi/mcp --api-key sk_live_... --base-url http://localhost:5000
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* @sellhapi/mcp — stdio entry point.
|
|
5
|
+
*
|
|
6
|
+
* For local AI tools (Claude Code, Cursor, etc.) that connect via stdio.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx @sellhapi/mcp --api-key sk_live_...
|
|
10
|
+
* npx @sellhapi/mcp --api-key sk_live_... --base-url http://localhost:5000
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
14
|
+
const client_js_1 = require("./client.js");
|
|
15
|
+
const server_js_1 = require("./server.js");
|
|
16
|
+
// ── Parse CLI args ───────────────────────────────────────────────────────────
|
|
17
|
+
function parseArgs() {
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
let apiKey;
|
|
20
|
+
let baseUrl;
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i] === "--api-key" && args[i + 1]) {
|
|
23
|
+
apiKey = args[++i];
|
|
24
|
+
}
|
|
25
|
+
else if (args[i] === "--base-url" && args[i + 1]) {
|
|
26
|
+
baseUrl = args[++i];
|
|
27
|
+
}
|
|
28
|
+
else if (args[i].startsWith("--api-key=")) {
|
|
29
|
+
apiKey = args[i].split("=")[1];
|
|
30
|
+
}
|
|
31
|
+
else if (args[i].startsWith("--base-url=")) {
|
|
32
|
+
baseUrl = args[i].split("=")[1];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
apiKey = apiKey ?? process.env.SELLHAPI_API_KEY;
|
|
36
|
+
baseUrl = baseUrl ?? process.env.SELLHAPI_BASE_URL;
|
|
37
|
+
if (!apiKey) {
|
|
38
|
+
console.error("Error: API key required. Use --api-key sk_live_... or set SELLHAPI_API_KEY env var.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return { apiKey, baseUrl };
|
|
42
|
+
}
|
|
43
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
async function main() {
|
|
45
|
+
const { apiKey, baseUrl } = parseArgs();
|
|
46
|
+
(0, client_js_1.configure)(apiKey, baseUrl);
|
|
47
|
+
const server = (0, server_js_1.createMcpServer)();
|
|
48
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
}
|
|
51
|
+
main().catch((err) => {
|
|
52
|
+
console.error("Fatal:", err);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BuildGuard rules — extracted from buildGuard.service.ts.
|
|
3
|
+
* Code that violates these rules will be rejected by push_code.
|
|
4
|
+
*/
|
|
5
|
+
export declare const BUILD_RULES = "\n# SellHapi BuildGuard Rules\n\nYour code MUST pass all these rules or push_code will reject it with specific violation details.\n\n## APPROVED IMPORTS (only these are allowed)\n\nExact matches:\n @sellhapi/studio-runtime\n react\n react-dom\n react-dom/client\n react/jsx-runtime\n react/jsx-dev-runtime\n framer-motion\n clsx\n date-fns\n zod\n lucide-react\n\nSubpath imports allowed:\n date-fns/*\n react-dom/*\n react/*\n\nRelative imports (./ and ../) are always allowed between project files.\n\nEVERYTHING ELSE IS BLOCKED \u2014 no axios, no tailwindcss, no lodash, no node builtins.\n\n## HARD VIOLATIONS (build fails)\n\n1. DISALLOWED IMPORTS\n Any import not in the approved list above.\n Violation: \"Import from X is not allowed.\"\n\n2. DYNAMIC REQUIRE\n require() with non-string argument or unapproved package.\n Violation: \"Dynamic require() is not allowed.\"\n\n3. DYNAMIC IMPORT\n import() expressions (as opposed to static import statements).\n Violation: \"Dynamic import() is not allowed in generated storefront code.\"\n\n4. CODE INJECTION\n eval(), new Function(), Function() \u2014 all blocked.\n Violation: \"eval()/Function() is not allowed \u2014 code injection risk.\"\n\n5. RAW NETWORK ACCESS\n fetch(), new XMLHttpRequest(), new WebSocket() \u2014 all blocked.\n Violation: \"Direct fetch() calls are not allowed. Use hooks from @sellhapi/studio-runtime instead.\"\n All data must flow through runtime hooks.\n\n ALLOWED: static external image URLs in <img src=\"...\"> are NOT fetch() calls and are permitted.\n Use Unsplash (https://images.unsplash.com/...) or picsum (https://picsum.photos/...) as placeholder\n fallbacks for hero images, category covers, or any section where the vendor has no uploaded image.\n Example: <img src={product.imageUrl || \"https://images.unsplash.com/photo-1517048676732-d65bc937f952?w=800\"} />\n This is valid \u2014 only JavaScript fetch()/XHR/WebSocket calls are blocked, not <img src> URLs.\n\n6. DANGEROUS GLOBALS\n Blocked property access:\n - process.env\n - document.cookie, document.write(), document.writeln(), document.domain, document.execCommand()\n - window.fetch, window.eval, window.Function\n - window.XMLHttpRequest, window.WebSocket\n - window.localStorage, window.sessionStorage, window.indexedDB\n - navigator.sendBeacon\n - Standalone: localStorage, sessionStorage, indexedDB\n Violation: \"X is not allowed in storefront code.\"\n\n7. SUBSCRIPT ACCESS ON GLOBALS\n window[\"fetch\"], globalThis[\"eval\"], self[\"XMLHttpRequest\"] \u2014 all blocked.\n Violation: \"Subscript access on 'window' is not allowed.\"\n\n8. DANGEROUS JSX\n dangerouslySetInnerHTML \u2014 blocked.\n Violation: \"dangerouslySetInnerHTML is not allowed \u2014 XSS vector.\"\n\n9. MISSING CART DRAWER\n If any file uses useCart or addItem, <CartDrawer /> MUST be rendered somewhere.\n Violation: \"useCart/addItem is used but <CartDrawer /> is not rendered anywhere.\"\n\n## SOFT WARNINGS (build passes, but flagged)\n\n1. product.images[N].url \u2014 WRONG. images is string[], each element IS the URL.\n Fix: use images[N] directly or product.imageUrl for primary.\n\n2. addItem() without type field \u2014 CartItem.type is required.\n Fix: add type: 'physical' | 'digital' to every addItem() call.\n\n3. campaign.raised / campaign.goalAmount \u2014 WRONG field names.\n Fix: use campaign.raisedAmountKobo / campaign.goalAmountKobo (divide by 100 for display).\n\n4. ticketType.soldCount \u2014 WRONG. Use ticketType.sold.\n\n## REQUIRED: data-region ATTRIBUTES\n\nEvery section that renders live data MUST have a data-region attribute on its outermost container element.\nThis is required for Studio's region selector, Layers panel, and entity injection to work.\n\nRequired attributes:\n data-region=\"<slug>\" \u2014 unique kebab-case identifier, e.g. \"hero\", \"products\", \"appointments\"\n data-label=\"<name>\" \u2014 human-readable label shown in Studio UI, e.g. \"Products Section\"\n data-scope=\"content\" \u2014 always \"content\" for data-driven sections\n\nExample:\n <section data-region=\"products\" data-label=\"Products\" data-scope=\"content\">\n {/* useProducts() renders here */}\n </section>\n\n <section data-region=\"appointments\" data-label=\"Consultations\" data-scope=\"content\">\n {/* useServices() renders here */}\n </section>\n\nALSO add data-region to static layout sections (hero, footer, nav) \u2014 these don't need data hooks but must still be selectable:\n <section data-region=\"hero\" data-label=\"Hero\" data-scope=\"layout\">\n <header data-region=\"navigation\" data-label=\"Navigation\" data-scope=\"navigation\">\n <footer data-region=\"footer\" data-label=\"Footer\" data-scope=\"layout\">\n\nIf you use ANY data hook (useProducts, useServices, useEvents, etc.) without ANY data-region attributes, the build will warn you.\n\n## FILE STRUCTURE\n\nRequired: App.tsx (or App.jsx) as entry point.\nRecommended structure:\n App.tsx \u2014 main component with routing\n components/ \u2014 reusable UI components\n pages/ \u2014 route-level pages (ProductPage, EventPage, etc.)\n styles/ \u2014 optional shared styles\n\n## BUNDLE SIZE LIMITS\n\nHard limit: 4 MB (build fails above this)\nWarning: 3.5 MB (runtime baseline is ~2.1 MB \u2014 aim to keep user code under 1.4 MB)\n\n## STYLING\n\nUse inline styles (React style objects) \u2014 this is the convention.\nCSS modules work but are not typical.\nTailwind is NOT available (not in approved imports).\nframer-motion is available for animations.\n\n## KEY PATTERNS\n\n1. All data comes from hooks. NEVER hardcode product names, prices, or content.\n2. Show loading states: if (loading) return <div>Loading...</div>\n3. Show empty states: if (data.length === 0) return <div>No items yet</div>\n4. Format prices: (priceKobo / 100).toLocaleString() for Naira display\n5. Use useHashRouter() for multi-page navigation\n6. Commerce actions return { paymentLink } \u2014 redirect: window.location.href = result.paymentLink\n";
|