@expander/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @expander/mcp-server
2
+
3
+ Model Context Protocol (MCP) server for the [Exmachine External API](https://api.exmachine.io). Lets you query Exmachine data from **Claude Desktop** using your API key.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. An Exmachine account with access to the data you need.
8
+ 2. An API key from **Settings → API Keys** in the Exmachine dashboard.
9
+ 3. [Node.js 20+](https://nodejs.org/) (for `npx`).
10
+
11
+ ## Claude Desktop setup
12
+
13
+ 1. Open **Claude Desktop → Settings → Developer → Edit Config**.
14
+ 2. Add the `expander` server under `mcpServers`:
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "expander": {
20
+ "command": "npx",
21
+ "args": ["-y", "@expander/mcp-server"],
22
+ "env": {
23
+ "EXMACHINE_API_KEY": "exp_live_your_key_here",
24
+ "EXMACHINE_API_BASE": "https://api.exmachine.io/api/v1"
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ **Config file paths**
32
+
33
+ | OS | Path |
34
+ |----|------|
35
+ | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
36
+ | Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
37
+
38
+ For **local development** against your gateway:
39
+
40
+ ```json
41
+ "EXMACHINE_API_BASE": "http://localhost:8080/api/v1"
42
+ ```
43
+
44
+ Quit and reopen Claude Desktop after saving.
45
+
46
+ ## Environment variables
47
+
48
+ | Variable | Required | Default | Description |
49
+ |----------|----------|---------|-------------|
50
+ | `EXMACHINE_API_KEY` | Yes | — | Your `exp_live_…` API key |
51
+ | `EXMACHINE_API_BASE` | No | `https://api.exmachine.io/api/v1` | API root (includes `/api/v1`) |
52
+
53
+ ## Tools
54
+
55
+ ### Catalog (discover ids first)
56
+
57
+ | Tool | Description |
58
+ |------|-------------|
59
+ | `list_marketplaces` | Active marketplaces → use `_id` as `marketplace_id` |
60
+ | `list_brands` | Brands for a marketplace → use `id` as `brand_id` |
61
+ | `list_business_partners` | Suppliers, forwarders, or warehouses (`type` param) |
62
+
63
+ ### Data
64
+
65
+ | Tool | Calls |
66
+ |------|--------|
67
+ | `update_shipment_status` | `PATCH /external/shipments/{ex_number}/status` |
68
+ | `get_shipment_by_ex_number` | `GET /external/shipments/{ex_number}` |
69
+ | `list_shipments` | `GET /external/shipments` |
70
+ | `list_products` | `GET /external/products` |
71
+ | `list_parent_groups` | `GET /external/parent-groups` |
72
+ | `list_sellerboard` | `GET /external/sellerboard` |
73
+ | `list_fba_orders` | `GET /external/amazon-reports/fba-orders` |
74
+ | … | Other Amazon report tools |
75
+
76
+ Most data tools require `marketplace_id` and `brand_id`. Call catalog tools first.
77
+
78
+ ## Example prompts (in Claude)
79
+
80
+ - “List marketplaces and brands for Amazon.ca, then show shipments in BOOKING status.”
81
+ - “List forwarders and filter OCEAN shipments for brand Pelegon.”
82
+ - “Pull Sellerboard data for last month for marketplace X and brand Y.”
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ cd expander-mcp
88
+ npm install
89
+ npm run build
90
+ EXMACHINE_API_KEY=exp_live_... EXMACHINE_API_BASE=http://localhost:8080/api/v1 npm start
91
+ ```
92
+
93
+ ## Security
94
+
95
+ - Never commit API keys. Use env vars in the Claude config only.
96
+ - The MCP server runs locally and forwards your key to the Exmachine API on each tool call.
97
+ - Rate limit: 60 requests/minute per key (same as REST).
98
+ - Dashboard permissions apply — denied endpoints return `403 forbidden`.
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,21 @@
1
+ import type { ExmachineConfig } from './config.js';
2
+ export declare class ExmachineApiClient {
3
+ private readonly config;
4
+ constructor(config: ExmachineConfig);
5
+ get(path: string, query?: Record<string, string | undefined>): Promise<unknown>;
6
+ patch(path: string, body?: Record<string, unknown>): Promise<unknown>;
7
+ private request;
8
+ }
9
+ export declare function jsonToolResult(data: unknown): {
10
+ content: Array<{
11
+ type: 'text';
12
+ text: string;
13
+ }>;
14
+ };
15
+ export declare function errorToolResult(message: string): {
16
+ content: Array<{
17
+ type: 'text';
18
+ text: string;
19
+ }>;
20
+ isError: true;
21
+ };
package/dist/client.js ADDED
@@ -0,0 +1,63 @@
1
+ export class ExmachineApiClient {
2
+ config;
3
+ constructor(config) {
4
+ this.config = config;
5
+ }
6
+ async get(path, query) {
7
+ return this.request('GET', path, { query });
8
+ }
9
+ async patch(path, body) {
10
+ return this.request('PATCH', path, { body });
11
+ }
12
+ async request(method, path, options) {
13
+ const url = new URL(`${this.config.apiBase}${path.startsWith('/') ? path : `/${path}`}`);
14
+ if (options?.query) {
15
+ for (const [key, value] of Object.entries(options.query)) {
16
+ const trimmed = value?.trim();
17
+ if (trimmed) {
18
+ url.searchParams.set(key, trimmed);
19
+ }
20
+ }
21
+ }
22
+ const response = await fetch(url, {
23
+ method,
24
+ headers: {
25
+ 'x-api-key': this.config.apiKey,
26
+ Accept: 'application/json',
27
+ ...(options?.body ? { 'Content-Type': 'application/json' } : {}),
28
+ },
29
+ body: options?.body ? JSON.stringify(options.body) : undefined,
30
+ });
31
+ const bodyText = await response.text();
32
+ let body = bodyText;
33
+ if (bodyText) {
34
+ try {
35
+ body = JSON.parse(bodyText);
36
+ }
37
+ catch {
38
+ body = bodyText;
39
+ }
40
+ }
41
+ if (!response.ok) {
42
+ const message = typeof body === 'object' &&
43
+ body !== null &&
44
+ 'message' in body &&
45
+ typeof body.message === 'string'
46
+ ? body.message
47
+ : `HTTP ${response.status} ${response.statusText}`;
48
+ throw new Error(message);
49
+ }
50
+ return body;
51
+ }
52
+ }
53
+ export function jsonToolResult(data) {
54
+ return {
55
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
56
+ };
57
+ }
58
+ export function errorToolResult(message) {
59
+ return {
60
+ content: [{ type: 'text', text: message }],
61
+ isError: true,
62
+ };
63
+ }
@@ -0,0 +1,5 @@
1
+ export interface ExmachineConfig {
2
+ apiKey: string;
3
+ apiBase: string;
4
+ }
5
+ export declare function loadConfig(): ExmachineConfig;
package/dist/config.js ADDED
@@ -0,0 +1,9 @@
1
+ const DEFAULT_API_BASE = 'https://api.exmachine.io/api/v1';
2
+ export function loadConfig() {
3
+ const apiKey = process.env.EXMACHINE_API_KEY?.trim();
4
+ if (!apiKey) {
5
+ throw new Error('EXMACHINE_API_KEY is required. Create a key in Exmachine Settings → API Keys.');
6
+ }
7
+ const apiBase = (process.env.EXMACHINE_API_BASE?.trim() || DEFAULT_API_BASE).replace(/\/$/, '');
8
+ return { apiKey, apiBase };
9
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { loadConfig } from './config.js';
5
+ import { ExmachineApiClient } from './client.js';
6
+ import { registerExmachineTools } from './tools.js';
7
+ async function main() {
8
+ const config = loadConfig();
9
+ const client = new ExmachineApiClient(config);
10
+ const server = new McpServer({
11
+ name: 'expander-exmachine',
12
+ version: '0.1.0',
13
+ });
14
+ registerExmachineTools(server, client);
15
+ const transport = new StdioServerTransport();
16
+ await server.connect(transport);
17
+ }
18
+ main().catch((error) => {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ console.error(`Expander MCP server failed: ${message}`);
21
+ process.exit(1);
22
+ });
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ExmachineApiClient } from './client.js';
3
+ export declare function registerExmachineTools(server: McpServer, client: ExmachineApiClient): void;
package/dist/tools.js ADDED
@@ -0,0 +1,245 @@
1
+ import { z } from 'zod';
2
+ import { errorToolResult, jsonToolResult } from './client.js';
3
+ const marketplaceId = z.string().min(1).describe('Marketplace _id from list_marketplaces');
4
+ const brandId = z.string().min(1).describe('Brand id from list_brands');
5
+ const limit = z
6
+ .number()
7
+ .int()
8
+ .min(1)
9
+ .max(1000)
10
+ .optional()
11
+ .describe('Page size (1–1000, default 100)');
12
+ const cursor = z.string().optional().describe('Pagination cursor from prior response next_cursor');
13
+ const marketplaceBrandSchema = z.object({
14
+ marketplace_id: marketplaceId,
15
+ brand_id: brandId,
16
+ });
17
+ const paginatedMarketplaceBrandSchema = marketplaceBrandSchema.extend({
18
+ limit,
19
+ cursor,
20
+ });
21
+ async function runTool(client, path, query) {
22
+ try {
23
+ const data = await client.get(path, query);
24
+ return jsonToolResult(data);
25
+ }
26
+ catch (err) {
27
+ return errorToolResult(err instanceof Error ? err.message : String(err));
28
+ }
29
+ }
30
+ async function runPatchTool(client, path, body) {
31
+ try {
32
+ const data = await client.patch(path, body);
33
+ return jsonToolResult(data);
34
+ }
35
+ catch (err) {
36
+ return errorToolResult(err instanceof Error ? err.message : String(err));
37
+ }
38
+ }
39
+ function optionalString(value) {
40
+ const trimmed = value?.trim();
41
+ return trimmed ? trimmed : undefined;
42
+ }
43
+ const shipmentStatus = z
44
+ .enum([
45
+ 'NEW',
46
+ 'ORDER_CONFIRMATION',
47
+ 'BOOKING',
48
+ 'ON_BOARD',
49
+ 'CUSTOMS',
50
+ 'CUSTOMS_RELEASED',
51
+ 'ARRIVAL',
52
+ 'DELIVERED',
53
+ 'CLOSED',
54
+ ])
55
+ .describe('Target pipeline status');
56
+ export function registerExmachineTools(server, client) {
57
+ server.registerTool('list_marketplaces', {
58
+ description: 'List all active marketplaces. Use returned _id as marketplace_id on other tools.',
59
+ inputSchema: z.object({}),
60
+ }, async () => runTool(client, '/external/marketplaces'));
61
+ server.registerTool('list_brands', {
62
+ description: 'List active brands linked to a marketplace.',
63
+ inputSchema: z.object({ marketplace_id: marketplaceId }),
64
+ }, async ({ marketplace_id }) => runTool(client, '/external/brands', { marketplace_id }));
65
+ server.registerTool('list_business_partners', {
66
+ description: 'List active business partners by type: supplier, forwarder, or warehouse. Use forwarder _id as forwarder_id on list_shipments.',
67
+ inputSchema: z.object({
68
+ type: z
69
+ .enum(['supplier', 'forwarder', 'warehouse'])
70
+ .describe('Partner kind'),
71
+ }),
72
+ }, async ({ type }) => runTool(client, '/external/business-partners', { type }));
73
+ server.registerTool('get_shipment_by_ex_number', {
74
+ description: 'Look up one shipment by ExNumber (e.g. EX013994) and return full details including product lines, origin, destination, and parties.',
75
+ inputSchema: z.object({
76
+ ex_number: z
77
+ .string()
78
+ .min(1)
79
+ .describe('Shipment ExNumber — accepts EX013994, ex-013994, or EX 013994'),
80
+ }),
81
+ }, async ({ ex_number }) => {
82
+ const normalized = ex_number.trim().toUpperCase().replace(/[\s-]/g, '');
83
+ return runTool(client, `/external/shipments/${encodeURIComponent(normalized)}`);
84
+ });
85
+ server.registerTool('update_shipment_status', {
86
+ description: 'Update a shipment pipeline status by ExNumber. Uses the same backend rules as the Exmachine dashboard (inventory transactions, required documents for CLOSED, order planning). Requires Supply Chain Edit permission.',
87
+ inputSchema: z.object({
88
+ ex_number: z
89
+ .string()
90
+ .min(1)
91
+ .describe('Shipment ExNumber — accepts EX013994, ex-013994, or EX 013994'),
92
+ status: shipmentStatus,
93
+ }),
94
+ }, async ({ ex_number, status }) => {
95
+ const normalized = ex_number.trim().toUpperCase().replace(/[\s-]/g, '');
96
+ return runPatchTool(client, `/external/shipments/${encodeURIComponent(normalized)}/status`, {
97
+ status,
98
+ });
99
+ });
100
+ server.registerTool('list_shipments', {
101
+ description: 'List active shipments for a marketplace and brand. Optional filters: status, forwarder_id, type.',
102
+ inputSchema: paginatedMarketplaceBrandSchema.extend({
103
+ status: z
104
+ .string()
105
+ .optional()
106
+ .describe('Comma-separated statuses: NEW, ORDER_CONFIRMATION, BOOKING, ON_BOARD, CUSTOMS, CUSTOMS_RELEASED, ARRIVAL, DELIVERED, CLOSED'),
107
+ forwarder_id: z
108
+ .string()
109
+ .optional()
110
+ .describe('Forwarder BusinessPartner _id (comma-separated for multiple)'),
111
+ type: z
112
+ .string()
113
+ .optional()
114
+ .describe('Comma-separated transport types: OCEAN, AIR, AIR_EXPRESS, OCEAN_EXPRESS, INLAND'),
115
+ }),
116
+ }, async (args) => runTool(client, '/external/shipments', {
117
+ marketplace_id: args.marketplace_id,
118
+ brand_id: args.brand_id,
119
+ limit: args.limit != null ? String(args.limit) : undefined,
120
+ cursor: optionalString(args.cursor),
121
+ status: optionalString(args.status),
122
+ forwarder_id: optionalString(args.forwarder_id),
123
+ type: optionalString(args.type),
124
+ }));
125
+ server.registerTool('list_products', {
126
+ description: 'List product catalog rows for a marketplace and brand.',
127
+ inputSchema: paginatedMarketplaceBrandSchema.extend({
128
+ sku: z.string().optional().describe('Filter SKUs (case-insensitive contains)'),
129
+ asin: z.string().optional().describe('Filter ASINs (case-insensitive contains)'),
130
+ active: z
131
+ .enum(['true', 'false'])
132
+ .optional()
133
+ .describe('Filter by active flag'),
134
+ }),
135
+ }, async (args) => runTool(client, '/external/products', {
136
+ marketplace_id: args.marketplace_id,
137
+ brand_id: args.brand_id,
138
+ limit: args.limit != null ? String(args.limit) : undefined,
139
+ cursor: optionalString(args.cursor),
140
+ sku: optionalString(args.sku),
141
+ asin: optionalString(args.asin),
142
+ active: args.active,
143
+ }));
144
+ server.registerTool('list_parent_groups', {
145
+ description: 'List parent product groups with nested product rows.',
146
+ inputSchema: paginatedMarketplaceBrandSchema.extend({
147
+ parent_name: z.string().optional().describe('Filter group names (contains)'),
148
+ sku: z.string().optional(),
149
+ asin: z.string().optional(),
150
+ }),
151
+ }, async (args) => runTool(client, '/external/parent-groups', {
152
+ marketplace_id: args.marketplace_id,
153
+ brand_id: args.brand_id,
154
+ limit: args.limit != null ? String(args.limit) : undefined,
155
+ cursor: optionalString(args.cursor),
156
+ parent_name: optionalString(args.parent_name),
157
+ sku: optionalString(args.sku),
158
+ asin: optionalString(args.asin),
159
+ }));
160
+ server.registerTool('list_sellerboard', {
161
+ description: 'List Sellerboard daily rows for a marketplace, brand, and date range.',
162
+ inputSchema: marketplaceBrandSchema.extend({
163
+ from: z.string().describe('Start date YYYY-MM-DD (inclusive)'),
164
+ to: z.string().describe('End date YYYY-MM-DD (inclusive)'),
165
+ limit,
166
+ cursor,
167
+ sku: z.string().optional(),
168
+ asin: z.string().optional(),
169
+ }),
170
+ }, async (args) => runTool(client, '/external/sellerboard', {
171
+ marketplace_id: args.marketplace_id,
172
+ brand_id: args.brand_id,
173
+ from: args.from,
174
+ to: args.to,
175
+ limit: args.limit != null ? String(args.limit) : undefined,
176
+ cursor: optionalString(args.cursor),
177
+ sku: optionalString(args.sku),
178
+ asin: optionalString(args.asin),
179
+ }));
180
+ const amazonReportSchema = marketplaceBrandSchema.extend({
181
+ date: z.string().describe('Report date YYYY-MM-DD'),
182
+ sku: z.string().optional(),
183
+ asin: z.string().optional(),
184
+ });
185
+ server.registerTool('list_fba_orders', {
186
+ description: 'Amazon FBA order report rows for one marketplace, brand, and day.',
187
+ inputSchema: amazonReportSchema,
188
+ }, async (args) => runTool(client, '/external/amazon-reports/fba-orders', {
189
+ marketplace_id: args.marketplace_id,
190
+ brand_id: args.brand_id,
191
+ date: args.date,
192
+ sku: optionalString(args.sku),
193
+ asin: optionalString(args.asin),
194
+ }));
195
+ server.registerTool('list_fba_returns', {
196
+ description: 'Amazon FBA customer returns for one marketplace, brand, and day.',
197
+ inputSchema: amazonReportSchema,
198
+ }, async (args) => runTool(client, '/external/amazon-reports/fba-returns', {
199
+ marketplace_id: args.marketplace_id,
200
+ brand_id: args.brand_id,
201
+ date: args.date,
202
+ sku: optionalString(args.sku),
203
+ asin: optionalString(args.asin),
204
+ }));
205
+ server.registerTool('list_financial_transactions', {
206
+ description: 'Amazon financial transactions for one marketplace, brand, and day.',
207
+ inputSchema: amazonReportSchema,
208
+ }, async (args) => runTool(client, '/external/amazon-reports/financial-transactions', {
209
+ marketplace_id: args.marketplace_id,
210
+ brand_id: args.brand_id,
211
+ date: args.date,
212
+ sku: optionalString(args.sku),
213
+ asin: optionalString(args.asin),
214
+ }));
215
+ server.registerTool('list_fba_estimated_fees', {
216
+ description: 'Amazon FBA estimated fees snapshot for marketplace, brand, and date.',
217
+ inputSchema: amazonReportSchema,
218
+ }, async (args) => runTool(client, '/external/amazon-reports/fba-estimated-fees', {
219
+ marketplace_id: args.marketplace_id,
220
+ brand_id: args.brand_id,
221
+ date: args.date,
222
+ sku: optionalString(args.sku),
223
+ asin: optionalString(args.asin),
224
+ }));
225
+ server.registerTool('list_reserved_inventory', {
226
+ description: 'Amazon reserved inventory snapshot for marketplace, brand, and date.',
227
+ inputSchema: amazonReportSchema,
228
+ }, async (args) => runTool(client, '/external/amazon-reports/reserved-inventory', {
229
+ marketplace_id: args.marketplace_id,
230
+ brand_id: args.brand_id,
231
+ date: args.date,
232
+ sku: optionalString(args.sku),
233
+ asin: optionalString(args.asin),
234
+ }));
235
+ server.registerTool('list_fba_manage_inventory', {
236
+ description: 'Amazon FBA manage inventory snapshot for marketplace, brand, and date.',
237
+ inputSchema: amazonReportSchema,
238
+ }, async (args) => runTool(client, '/external/amazon-reports/fba-manage-inventory', {
239
+ marketplace_id: args.marketplace_id,
240
+ brand_id: args.brand_id,
241
+ date: args.date,
242
+ sku: optionalString(args.sku),
243
+ asin: optionalString(args.asin),
244
+ }));
245
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@expander/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for the Exmachine External API",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "expander-mcp": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p .",
16
+ "start": "node dist/index.js",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "exmachine",
26
+ "expander",
27
+ "external-api"
28
+ ],
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "zod": "^3.25.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.15.0",
39
+ "typescript": "^5.8.0"
40
+ }
41
+ }