@harukibox/mcp 0.1.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 ADDED
@@ -0,0 +1,97 @@
1
+ # @harukibox/mcp
2
+
3
+ Model Context Protocol server for [harukibox](https://harukibox.com) — let your AI agent manage your 代購 inventory, orders, buyers and shipments through the official Agent API.
4
+
5
+ ## 安裝
6
+
7
+ ```bash
8
+ npm install -g @harukibox/mcp
9
+ ```
10
+
11
+ 或在專案內:
12
+
13
+ ```bash
14
+ npm install @harukibox/mcp
15
+ ```
16
+
17
+ ## 取得 API token
18
+
19
+ 透過 `@harukibox/cli` 跑 OAuth 取得(90 天有效):
20
+
21
+ ```bash
22
+ npm install -g @harukibox/cli
23
+ harukibox login # PKCE,預設打開瀏覽器
24
+ # 或無 GUI 環境:
25
+ harukibox login --device # device flow,顯示 user_code 讓你在瀏覽器輸入
26
+ ```
27
+
28
+ 跑完 token 會寫進 `~/.config/harukibox/config.json`(mode 0600)。把該 token 設成 `HARUKIBOX_TOKEN` 即可。
29
+
30
+ ## Claude Desktop 設定
31
+
32
+ 編輯 `~/Library/Application Support/Claude/claude_desktop_config.json`:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "harukibox": {
38
+ "command": "harukibox-mcp",
39
+ "env": {
40
+ "HARUKIBOX_TOKEN": "hrk_live_your_token_here"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ### Refresh token (進階, 預設關閉)
48
+
49
+ 如果你跑 long-lived MCP process(不常重啟),可開 refresh:
50
+
51
+ ```json
52
+ "env": {
53
+ "HARUKIBOX_TOKEN": "hrk_live_...",
54
+ "HARUKIBOX_ENABLE_REFRESH": "1",
55
+ "HARUKIBOX_REFRESH_TOKEN": "hrk_refresh_..."
56
+ }
57
+ ```
58
+
59
+ ⚠️ **限制**:refresh rotation 後新 refresh 只在 process memory;process 重啟時 env 仍是舊 refresh,server 會視為 reuse 並撤該 user **所有 token**。重啟前請手動執行 `harukibox login` 取新 token。建議 production 不開此選項,依賴 90 天到期後 re-OAuth。
60
+
61
+ ## Claude Code 設定
62
+
63
+ ```bash
64
+ claude mcp add harukibox --command harukibox-mcp --env HARUKIBOX_TOKEN=hrk_live_your_token_here
65
+ ```
66
+
67
+ ## 可用工具
68
+
69
+ | Tool | 必要 scope | 說明 |
70
+ |------|----------|------|
71
+ | `haruki_me` | `me` | 取得當前 user/organization/token info |
72
+ | `haruki_list_products` | `products:read` | 列出商品 |
73
+ | `haruki_get_product` | `products:read` | 取得單一商品 |
74
+ | `haruki_create_product` | `products:write` | 建立商品 |
75
+ | `haruki_list_registrations` | `registrations:read` | 列出訂單 |
76
+ | `haruki_list_buyers` | `buyers:read` | 列出買家 |
77
+ | `haruki_list_shippings` | `shippings:read` | 列出出貨 |
78
+ | `haruki_search` | `search` | 全域搜尋 |
79
+
80
+ ## 安全
81
+
82
+ - Token 一律以環境變數提供,不會寫入磁碟
83
+ - 所有請求走 HTTPS 並帶 sha256 hash 驗證
84
+ - 過期 / 撤銷 / IP allowlist 不符時請求會立刻被拒
85
+ - 每次呼叫會被寫入伺服器端 audit log(沒有 web UI 查詢;如需匯出請與服務方聯繫)
86
+
87
+ ## 自架 / 私有部署
88
+
89
+ ```bash
90
+ HARUKIBOX_BASE_URL=https://your-instance.example.com/api/agent \
91
+ HARUKIBOX_TOKEN=hrk_live_... \
92
+ harukibox-mcp
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Built entry point — assumes `npm run build` has produced dist/.
3
+ import('../dist/index.js').catch((err) => {
4
+ console.error('[harukibox-mcp] failed to start:', err?.message || err);
5
+ console.error('[harukibox-mcp] did you run `npm run build` inside packages/haruki-mcp?');
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Thin fetch wrapper for the harukibox Agent API.
3
+ */
4
+ const DEFAULT_BASE_URL = 'https://harukibox.com/api/agent';
5
+ export class HarukiAgentClient {
6
+ token;
7
+ refreshToken;
8
+ baseUrl;
9
+ userAgent;
10
+ timeoutMs;
11
+ /** OAuth /token endpoint (computed from baseUrl: strip /api/agent suffix). */
12
+ tokenEndpoint;
13
+ constructor(opts) {
14
+ if (!opts.token)
15
+ throw new Error('HarukiAgentClient requires a token');
16
+ this.token = opts.token;
17
+ this.refreshToken = opts.refreshToken;
18
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
19
+ this.userAgent = opts.userAgent || 'harukibox-mcp/0.1.1';
20
+ this.timeoutMs = opts.timeoutMs ?? 30_000;
21
+ // baseUrl 通常是 https://harukibox.com/api/agent;OAuth /token 在 https://harukibox.com/api/auth/oauth/token
22
+ const origin = this.baseUrl.replace(/\/api\/agent$/, '');
23
+ this.tokenEndpoint = `${origin}/api/auth/oauth/token`;
24
+ }
25
+ /** 401 時用 refresh token 換新 access;in-memory swap,process restart 後失效。*/
26
+ async tryRefresh() {
27
+ if (!this.refreshToken)
28
+ return false;
29
+ try {
30
+ const res = await fetch(this.tokenEndpoint, {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify({
34
+ grant_type: 'refresh_token',
35
+ refresh_token: this.refreshToken,
36
+ client_id: 'harukibox-cli',
37
+ }),
38
+ });
39
+ if (!res.ok)
40
+ return false;
41
+ const j = (await res.json());
42
+ if (!j.access_token)
43
+ return false;
44
+ this.token = j.access_token;
45
+ if (j.refresh_token)
46
+ this.refreshToken = j.refresh_token;
47
+ console.error('[harukibox-mcp] access token expired, refreshed silently');
48
+ return true;
49
+ }
50
+ catch (err) {
51
+ console.error('[harukibox-mcp] refresh failed:', err);
52
+ return false;
53
+ }
54
+ }
55
+ async request(method, path, body, query) {
56
+ const url = new URL(`${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`);
57
+ if (query) {
58
+ for (const [k, v] of Object.entries(query)) {
59
+ if (v !== undefined && v !== null && v !== '') {
60
+ url.searchParams.set(k, String(v));
61
+ }
62
+ }
63
+ }
64
+ const doFetch = async () => {
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
67
+ try {
68
+ return await fetch(url.toString(), {
69
+ method,
70
+ headers: {
71
+ 'Authorization': `Bearer ${this.token}`,
72
+ 'User-Agent': this.userAgent,
73
+ 'Accept': 'application/json',
74
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
75
+ },
76
+ body: body ? JSON.stringify(body) : undefined,
77
+ signal: controller.signal,
78
+ });
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ };
84
+ try {
85
+ let res = await doFetch();
86
+ // 401 + 有 refresh token → refresh + 重試一次
87
+ if (res.status === 401 && this.refreshToken) {
88
+ const refreshed = await this.tryRefresh();
89
+ if (refreshed)
90
+ res = await doFetch();
91
+ }
92
+ const text = await res.text();
93
+ let parsed;
94
+ try {
95
+ parsed = text ? JSON.parse(text) : { success: res.ok };
96
+ }
97
+ catch {
98
+ return { success: false, message: `Non-JSON response (${res.status})` };
99
+ }
100
+ if (!res.ok) {
101
+ const obj = parsed;
102
+ return {
103
+ success: false,
104
+ message: typeof obj?.message === 'string' ? obj.message : `HTTP ${res.status}`,
105
+ code: typeof obj?.code === 'string' ? obj.code : undefined,
106
+ };
107
+ }
108
+ return parsed;
109
+ }
110
+ catch (err) {
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ return { success: false, message: msg };
113
+ }
114
+ }
115
+ me() { return this.request('GET', '/me'); }
116
+ listProducts(params) {
117
+ return this.request('GET', '/products', undefined, params);
118
+ }
119
+ getProduct(id) { return this.request('GET', `/products/${encodeURIComponent(id)}`); }
120
+ createProduct(payload) { return this.request('POST', '/products', payload); }
121
+ listRegistrations(params) {
122
+ return this.request('GET', '/registrations', undefined, params);
123
+ }
124
+ listBuyers(params) {
125
+ return this.request('GET', '/buyers', undefined, params);
126
+ }
127
+ listShippings(params) {
128
+ return this.request('GET', '/shippings', undefined, params);
129
+ }
130
+ search(q, type) {
131
+ return this.request('GET', '/search', undefined, { q, type });
132
+ }
133
+ }
package/dist/index.js ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * harukibox MCP server.
4
+ *
5
+ * Connect this server to Claude Desktop, Claude Code or any MCP-compatible
6
+ * client. It exposes the harukibox Agent API as MCP tools.
7
+ *
8
+ * Authentication: set HARUKIBOX_TOKEN env var (run `harukibox login` from
9
+ * @harukibox/cli to obtain one via OAuth). Base URL can be overridden via
10
+ * HARUKIBOX_BASE_URL.
11
+ */
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
15
+ import { HarukiAgentClient } from './api-client.js';
16
+ const SERVER_NAME = 'harukibox';
17
+ const SERVER_VERSION = '0.1.1';
18
+ function getEnv(name, fallback) {
19
+ const v = process.env[name];
20
+ if (v && v.length > 0)
21
+ return v;
22
+ return fallback;
23
+ }
24
+ const token = getEnv('HARUKIBOX_TOKEN');
25
+ const enableRefresh = getEnv('HARUKIBOX_ENABLE_REFRESH') === '1';
26
+ const refreshToken = enableRefresh ? getEnv('HARUKIBOX_REFRESH_TOKEN') : undefined;
27
+ const baseUrl = getEnv('HARUKIBOX_BASE_URL', 'https://harukibox.com/api/agent');
28
+ if (!token) {
29
+ console.error('[harukibox-mcp] Missing HARUKIBOX_TOKEN environment variable.');
30
+ console.error('[harukibox-mcp] Run `harukibox login` (from @harukibox/cli) to obtain one.');
31
+ process.exit(1);
32
+ }
33
+ // codex 4th W3:refresh in MCP 預設 disable。
34
+ // 原因:rotation 後新 refresh 只在 process memory,restart 後 env 是舊 refresh
35
+ // → server 視為 reuse 撤該 user 整條 chain。
36
+ // User 知情後可設 HARUKIBOX_ENABLE_REFRESH=1 開啟(建議搭配 long-lived process / 不重啟)。
37
+ if (enableRefresh && refreshToken) {
38
+ console.error('[harukibox-mcp] HARUKIBOX_ENABLE_REFRESH=1 + HARUKIBOX_REFRESH_TOKEN detected.');
39
+ console.error('[harukibox-mcp] 401 will trigger silent refresh; process restart will INVALIDATE the chain.');
40
+ }
41
+ else if (enableRefresh) {
42
+ console.error('[harukibox-mcp] HARUKIBOX_ENABLE_REFRESH=1 but HARUKIBOX_REFRESH_TOKEN missing; refresh disabled.');
43
+ }
44
+ const client = new HarukiAgentClient({
45
+ token,
46
+ refreshToken,
47
+ baseUrl,
48
+ userAgent: `${SERVER_NAME}-mcp/${SERVER_VERSION}`,
49
+ });
50
+ /**
51
+ * Annotations follow the MCP 2025-06 spec:
52
+ * - title: human-readable; clients (Claude Desktop, Anthropic Connector review)
53
+ * prefer this over tool name when displaying.
54
+ * - readOnlyHint: true → no observable state mutation.
55
+ * - destructiveHint: true → this can permanently change data the user cares about.
56
+ * Anthropic review requires destructive write tools to be marked.
57
+ * - openWorldHint: true → tool reaches out beyond the local process (HTTP API).
58
+ */
59
+ const tools = [
60
+ {
61
+ name: 'haruki_me',
62
+ title: 'Get account info',
63
+ description: 'Return the user, organization and token info bound to this MCP connection. Always call first to confirm which harukibox store the agent is operating against.',
64
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
65
+ annotations: {
66
+ title: 'Get account info',
67
+ readOnlyHint: true,
68
+ destructiveHint: false,
69
+ openWorldHint: true,
70
+ },
71
+ },
72
+ {
73
+ name: 'haruki_list_products',
74
+ title: 'List products',
75
+ description: 'List products in the connected harukibox store. Supports search (matches name + SKU), status filter and pagination.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ page: { type: 'integer', minimum: 1, default: 1 },
80
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
81
+ search: { type: 'string', description: 'Match against name and SKU.' },
82
+ status: { type: 'string', description: '現貨 / 預購 / 售罄 / active 等' },
83
+ },
84
+ additionalProperties: false,
85
+ },
86
+ annotations: {
87
+ title: 'List products',
88
+ readOnlyHint: true,
89
+ destructiveHint: false,
90
+ openWorldHint: true,
91
+ },
92
+ },
93
+ {
94
+ name: 'haruki_get_product',
95
+ title: 'Get product',
96
+ description: 'Fetch a single product by its UUID. Returns 404 if the product is not in the calling organization.',
97
+ inputSchema: {
98
+ type: 'object',
99
+ properties: { id: { type: 'string', description: 'Product UUID' } },
100
+ required: ['id'],
101
+ additionalProperties: false,
102
+ },
103
+ annotations: {
104
+ title: 'Get product',
105
+ readOnlyHint: true,
106
+ destructiveHint: false,
107
+ openWorldHint: true,
108
+ },
109
+ },
110
+ {
111
+ name: 'haruki_create_product',
112
+ title: 'Create product',
113
+ description: 'Create a new product. Requires the products:write scope. Will fail with QUOTA_EXCEEDED if the organization plan limit is reached.',
114
+ inputSchema: {
115
+ type: 'object',
116
+ properties: {
117
+ name: { type: 'string' },
118
+ sku: { type: 'string' },
119
+ priceTwd: { type: 'number' },
120
+ priceJpy: { type: 'number' },
121
+ description: { type: 'string' },
122
+ category: { type: 'string' },
123
+ status: { type: 'string' },
124
+ initialStock: { type: 'integer' },
125
+ releaseDate: { type: 'string' },
126
+ ipId: { type: 'string' },
127
+ locationId: { type: 'string' },
128
+ },
129
+ required: ['name', 'sku'],
130
+ additionalProperties: false,
131
+ },
132
+ annotations: {
133
+ title: 'Create product',
134
+ readOnlyHint: false,
135
+ destructiveHint: false, // 新建不算破壞性
136
+ openWorldHint: true,
137
+ },
138
+ },
139
+ {
140
+ name: 'haruki_list_registrations',
141
+ title: 'List registrations (orders)',
142
+ description: 'List orders (registrations) for the connected store. Filter by status or buyer.',
143
+ inputSchema: {
144
+ type: 'object',
145
+ properties: {
146
+ page: { type: 'integer', minimum: 1, default: 1 },
147
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
148
+ status: { type: 'string' },
149
+ buyerId: { type: 'string' },
150
+ },
151
+ additionalProperties: false,
152
+ },
153
+ annotations: {
154
+ title: 'List registrations',
155
+ readOnlyHint: true,
156
+ destructiveHint: false,
157
+ openWorldHint: true,
158
+ },
159
+ },
160
+ {
161
+ name: 'haruki_list_buyers',
162
+ title: 'List buyers',
163
+ description: 'List buyers in the connected store. Search matches across name / phone / email.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ page: { type: 'integer', minimum: 1, default: 1 },
168
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
169
+ search: { type: 'string' },
170
+ },
171
+ additionalProperties: false,
172
+ },
173
+ annotations: {
174
+ title: 'List buyers',
175
+ readOnlyHint: true,
176
+ destructiveHint: false,
177
+ openWorldHint: true,
178
+ },
179
+ },
180
+ {
181
+ name: 'haruki_list_shippings',
182
+ title: 'List shipments',
183
+ description: 'List shipments. Filter by status if provided.',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {
187
+ page: { type: 'integer', minimum: 1, default: 1 },
188
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
189
+ status: { type: 'string' },
190
+ },
191
+ additionalProperties: false,
192
+ },
193
+ annotations: {
194
+ title: 'List shipments',
195
+ readOnlyHint: true,
196
+ destructiveHint: false,
197
+ openWorldHint: true,
198
+ },
199
+ },
200
+ {
201
+ name: 'haruki_search',
202
+ title: 'Global search',
203
+ description: 'Search across products, buyers and registrations. Pass type to narrow the scope.',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ q: { type: 'string' },
208
+ type: { type: 'string', enum: ['products', 'buyers', 'registrations'] },
209
+ },
210
+ required: ['q'],
211
+ additionalProperties: false,
212
+ },
213
+ annotations: {
214
+ title: 'Global search',
215
+ readOnlyHint: true,
216
+ destructiveHint: false,
217
+ openWorldHint: true,
218
+ },
219
+ },
220
+ ];
221
+ const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
222
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
223
+ tools: tools.map((t) => ({ ...t })),
224
+ }));
225
+ /**
226
+ * MCP best practice: API failures must not throw at the protocol layer.
227
+ * Per spec, tool errors return content + isError: true so the LLM can
228
+ * see and react to them, rather than treat them as a server crash.
229
+ */
230
+ function toMcpResult(result) {
231
+ const r = result;
232
+ const isError = r && typeof r === 'object' && 'success' in r && r.success === false;
233
+ const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
234
+ return {
235
+ content: [{ type: 'text', text }],
236
+ ...(isError ? { isError: true } : {}),
237
+ };
238
+ }
239
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
240
+ const { name, arguments: args } = request.params;
241
+ const a = (args ?? {});
242
+ try {
243
+ let result;
244
+ switch (name) {
245
+ case 'haruki_me':
246
+ result = await client.me();
247
+ break;
248
+ case 'haruki_list_products':
249
+ result = await client.listProducts(a);
250
+ break;
251
+ case 'haruki_get_product':
252
+ if (typeof a.id !== 'string') {
253
+ return toMcpResult({ success: false, message: 'id is required (string)' });
254
+ }
255
+ result = await client.getProduct(a.id);
256
+ break;
257
+ case 'haruki_create_product':
258
+ result = await client.createProduct(a);
259
+ break;
260
+ case 'haruki_list_registrations':
261
+ result = await client.listRegistrations(a);
262
+ break;
263
+ case 'haruki_list_buyers':
264
+ result = await client.listBuyers(a);
265
+ break;
266
+ case 'haruki_list_shippings':
267
+ result = await client.listShippings(a);
268
+ break;
269
+ case 'haruki_search':
270
+ if (typeof a.q !== 'string') {
271
+ return toMcpResult({ success: false, message: 'q is required (string)' });
272
+ }
273
+ result = await client.search(a.q, a.type);
274
+ break;
275
+ default:
276
+ return toMcpResult({ success: false, message: `Unknown tool: ${name}` });
277
+ }
278
+ return toMcpResult(result);
279
+ }
280
+ catch (err) {
281
+ const msg = err instanceof Error ? err.message : String(err);
282
+ return toMcpResult({ success: false, message: `MCP runtime error: ${msg}` });
283
+ }
284
+ });
285
+ async function main() {
286
+ const transport = new StdioServerTransport();
287
+ await server.connect(transport);
288
+ console.error(`[harukibox-mcp] connected, baseUrl=${baseUrl}`);
289
+ }
290
+ main().catch((err) => {
291
+ console.error('[harukibox-mcp] fatal:', err);
292
+ process.exit(1);
293
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@harukibox/mcp",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "description": "Model Context Protocol server for harukibox — let your AI agent manage your inventory, orders, buyers and shipments through the official harukibox API.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "harukibox-mcp": "./bin/harukibox-mcp.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepack": "npm run build",
19
+ "start": "node ./bin/harukibox-mcp.js"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.0.0",
26
+ "typescript": "^5.0.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "homepage": "https://harukibox.com/agent-api",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/cosmopig/haruki"
35
+ }
36
+ }