@swatcha/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.
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Select Product Images</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ html, body { background: var(--color-background-primary, #1a1a2e); }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="./image-picker.tsx"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,423 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import { useState, useCallback } from 'react';
3
+ import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
4
+ import type { CallToolResult } from '@modelcontextprotocol/ext-apps';
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ interface ImageCandidate {
11
+ imageUrl: string;
12
+ thumbnailUrl: string;
13
+ title: string;
14
+ sourceDomain: string;
15
+ sourceUrl?: string;
16
+ }
17
+
18
+ interface ProductEntry {
19
+ productId: string;
20
+ productName: string;
21
+ supplier?: string;
22
+ candidates: ImageCandidate[];
23
+ }
24
+
25
+ interface PickerData {
26
+ products: ProductEntry[];
27
+ batchLabel?: string;
28
+ }
29
+
30
+ // ============================================================================
31
+ // Styles
32
+ // ============================================================================
33
+
34
+ const styles = {
35
+ body: {
36
+ fontFamily: 'var(--font-sans, system-ui, sans-serif)',
37
+ background: 'var(--color-background-primary, #1a1a2e)',
38
+ color: 'var(--color-text-primary, #e2e8f0)',
39
+ padding: 16,
40
+ },
41
+ header: {
42
+ display: 'flex' as const,
43
+ justifyContent: 'space-between' as const,
44
+ alignItems: 'baseline' as const,
45
+ marginBottom: 16,
46
+ },
47
+ title: { fontSize: 16, fontWeight: 600 },
48
+ batchInfo: {
49
+ fontSize: 12,
50
+ color: 'var(--color-text-secondary, #94a3b8)',
51
+ },
52
+ productSection: (isLast: boolean) => ({
53
+ marginBottom: 20,
54
+ paddingBottom: 16,
55
+ borderBottom: isLast ? 'none' : '1px solid var(--color-border, #334155)',
56
+ }),
57
+ productHeader: {
58
+ display: 'flex' as const,
59
+ justifyContent: 'space-between' as const,
60
+ alignItems: 'baseline' as const,
61
+ marginBottom: 8,
62
+ },
63
+ productName: { fontSize: 14, fontWeight: 500 },
64
+ productSupplier: {
65
+ fontSize: 12,
66
+ color: 'var(--color-text-secondary, #94a3b8)',
67
+ },
68
+ statusBadge: (state: 'selected' | 'skipped') => ({
69
+ fontSize: 11,
70
+ padding: '2px 8px',
71
+ borderRadius: 10,
72
+ background:
73
+ state === 'selected'
74
+ ? 'rgba(124, 58, 237, 0.15)'
75
+ : 'rgba(148, 163, 184, 0.1)',
76
+ color: state === 'selected' ? '#7c3aed' : 'var(--color-text-secondary, #94a3b8)',
77
+ }),
78
+ imagesRow: {
79
+ display: 'flex' as const,
80
+ gap: 8,
81
+ overflowX: 'auto' as const,
82
+ padding: '6px 2px',
83
+ },
84
+ imageCard: (isSelected: boolean) => ({
85
+ flex: '0 0 auto',
86
+ width: 130,
87
+ border: `2px solid ${isSelected ? '#7c3aed' : 'transparent'}`,
88
+ borderRadius: 8,
89
+ overflow: 'hidden' as const,
90
+ cursor: 'pointer' as const,
91
+ background: 'var(--color-background-secondary, #2a2a4a)',
92
+ transition: 'border-color 0.15s, transform 0.15s, opacity 0.15s',
93
+ transform: isSelected ? 'scale(1.05)' : 'scale(1)',
94
+ opacity: isSelected ? 1 : 0.6,
95
+ position: 'relative' as const,
96
+ }),
97
+ checkmark: {
98
+ position: 'absolute' as const,
99
+ top: 4,
100
+ right: 4,
101
+ width: 22,
102
+ height: 22,
103
+ borderRadius: '50%',
104
+ background: '#7c3aed',
105
+ display: 'flex' as const,
106
+ alignItems: 'center' as const,
107
+ justifyContent: 'center' as const,
108
+ zIndex: 1,
109
+ },
110
+ img: {
111
+ width: '100%',
112
+ height: 90,
113
+ objectFit: 'cover' as const,
114
+ display: 'block' as const,
115
+ },
116
+ meta: {
117
+ padding: '4px 6px',
118
+ fontSize: 10,
119
+ whiteSpace: 'nowrap' as const,
120
+ overflow: 'hidden' as const,
121
+ textOverflow: 'ellipsis' as const,
122
+ },
123
+ metaLink: {
124
+ color: 'var(--color-text-secondary, #94a3b8)',
125
+ textDecoration: 'none' as const,
126
+ },
127
+ metaText: {
128
+ color: 'var(--color-text-secondary, #94a3b8)',
129
+ },
130
+ skipLink: {
131
+ display: 'inline-block' as const,
132
+ marginTop: 6,
133
+ fontSize: 11,
134
+ color: 'var(--color-text-secondary, #94a3b8)',
135
+ cursor: 'pointer' as const,
136
+ textDecoration: 'underline' as const,
137
+ textUnderlineOffset: 2,
138
+ background: 'none',
139
+ border: 'none',
140
+ padding: 0,
141
+ fontFamily: 'inherit',
142
+ },
143
+ footer: {
144
+ marginTop: 16,
145
+ display: 'flex' as const,
146
+ gap: 8,
147
+ alignItems: 'center' as const,
148
+ },
149
+ btnPrimary: {
150
+ padding: '8px 16px',
151
+ border: 'none' as const,
152
+ borderRadius: 6,
153
+ fontSize: 13,
154
+ fontWeight: 500,
155
+ cursor: 'pointer' as const,
156
+ background: '#7c3aed',
157
+ color: 'white',
158
+ fontFamily: 'inherit',
159
+ },
160
+ status: {
161
+ fontSize: 12,
162
+ color: 'var(--color-text-secondary, #94a3b8)',
163
+ },
164
+ error: {
165
+ fontSize: 12,
166
+ color: '#ef4444',
167
+ marginTop: 8,
168
+ },
169
+ imgError: {
170
+ width: '100%',
171
+ height: 90,
172
+ display: 'flex' as const,
173
+ alignItems: 'center' as const,
174
+ justifyContent: 'center' as const,
175
+ fontSize: 11,
176
+ color: 'var(--color-text-secondary, #94a3b8)',
177
+ background: 'var(--color-background-secondary, #2a2a4a)',
178
+ },
179
+ } as const;
180
+
181
+ // ============================================================================
182
+ // Components
183
+ // ============================================================================
184
+
185
+ function CheckmarkIcon() {
186
+ return (
187
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
188
+ <polyline points="20 6 9 17 4 12" />
189
+ </svg>
190
+ );
191
+ }
192
+
193
+ function ImageCard({
194
+ candidate,
195
+ isSelected,
196
+ onClick,
197
+ }: {
198
+ candidate: ImageCandidate;
199
+ isSelected: boolean;
200
+ onClick: () => void;
201
+ }) {
202
+ const [imgError, setImgError] = useState(false);
203
+
204
+ return (
205
+ <div style={styles.imageCard(isSelected)} onClick={onClick}>
206
+ {isSelected && (
207
+ <div style={styles.checkmark}>
208
+ <CheckmarkIcon />
209
+ </div>
210
+ )}
211
+ {imgError ? (
212
+ <div style={styles.imgError}>Failed to load</div>
213
+ ) : (
214
+ <img
215
+ src={candidate.thumbnailUrl || candidate.imageUrl}
216
+ alt={candidate.title}
217
+ loading="lazy"
218
+ style={styles.img}
219
+ onError={() => setImgError(true)}
220
+ />
221
+ )}
222
+ <div style={styles.meta}>
223
+ {candidate.sourceUrl ? (
224
+ <a
225
+ href={candidate.sourceUrl}
226
+ target="_blank"
227
+ rel="noopener noreferrer"
228
+ onClick={(e) => e.stopPropagation()}
229
+ style={styles.metaLink}
230
+ >
231
+ {candidate.sourceDomain}
232
+ </a>
233
+ ) : (
234
+ <span style={styles.metaText}>{candidate.sourceDomain}</span>
235
+ )}
236
+ </div>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ function ProductSection({
242
+ product,
243
+ isLast,
244
+ selection,
245
+ onSelect,
246
+ onSkip,
247
+ }: {
248
+ product: ProductEntry;
249
+ isLast: boolean;
250
+ selection: string | null;
251
+ onSelect: (imageUrl: string) => void;
252
+ onSkip: () => void;
253
+ }) {
254
+ const isSkipped = selection === null;
255
+ const selectedIdx = product.candidates.findIndex((c) => c.imageUrl === selection);
256
+ const statusText =
257
+ product.candidates.length === 0
258
+ ? 'No candidates'
259
+ : isSkipped
260
+ ? 'Skipped'
261
+ : `Image ${selectedIdx + 1} selected`;
262
+ const statusState: 'selected' | 'skipped' =
263
+ product.candidates.length === 0 || isSkipped ? 'skipped' : 'selected';
264
+
265
+ return (
266
+ <div style={styles.productSection(isLast)}>
267
+ <div style={styles.productHeader}>
268
+ <div>
269
+ <span style={styles.productName}>{product.productName}</span>
270
+ {product.supplier && (
271
+ <span style={styles.productSupplier}> from {product.supplier}</span>
272
+ )}
273
+ </div>
274
+ <span style={styles.statusBadge(statusState)}>{statusText}</span>
275
+ </div>
276
+
277
+ {product.candidates.length > 0 && (
278
+ <>
279
+ <div style={styles.imagesRow}>
280
+ {product.candidates.map((candidate, idx) => (
281
+ <ImageCard
282
+ key={idx}
283
+ candidate={candidate}
284
+ isSelected={selection === candidate.imageUrl}
285
+ onClick={() => onSelect(candidate.imageUrl)}
286
+ />
287
+ ))}
288
+ </div>
289
+ <button style={styles.skipLink} onClick={onSkip}>
290
+ Skip this product
291
+ </button>
292
+ </>
293
+ )}
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function ImagePickerApp() {
299
+ const [pickerData, setPickerData] = useState<PickerData | null>(null);
300
+ const [selections, setSelections] = useState<Record<string, string | null>>({});
301
+ const [submitted, setSubmitted] = useState(false);
302
+ const [error, setError] = useState<string | null>(null);
303
+
304
+ const { app, isConnected, error: connError } = useApp({
305
+ appInfo: { name: 'ImagePicker', version: '0.1.0' },
306
+ capabilities: {},
307
+ onAppCreated: (createdApp) => {
308
+ createdApp.ontoolresult = (params: CallToolResult) => {
309
+ const data = params.structuredContent as PickerData | undefined;
310
+ if (!data?.products?.length) return;
311
+
312
+ setPickerData(data);
313
+
314
+ const initial: Record<string, string | null> = {};
315
+ data.products.forEach((p) => {
316
+ initial[p.productId] = p.candidates.length > 0 ? p.candidates[0].imageUrl : null;
317
+ });
318
+ setSelections(initial);
319
+ };
320
+ createdApp.onteardown = async () => ({});
321
+ },
322
+ });
323
+
324
+ useHostStyles(app);
325
+
326
+ const handleSelect = useCallback((productId: string, imageUrl: string) => {
327
+ setSelections((prev) => ({ ...prev, [productId]: imageUrl }));
328
+ }, []);
329
+
330
+ const handleSkip = useCallback((productId: string) => {
331
+ setSelections((prev) => ({ ...prev, [productId]: null }));
332
+ }, []);
333
+
334
+ const handleSave = useCallback(async () => {
335
+ if (!app || !pickerData) return;
336
+
337
+ const lines = pickerData.products.map((product) => {
338
+ const imageUrl = selections[product.productId];
339
+ return imageUrl
340
+ ? `- "${product.productName}" (${product.productId}): ${imageUrl}`
341
+ : `- "${product.productName}" (${product.productId}): SKIP`;
342
+ });
343
+
344
+ const message = `Image selections:\n${lines.join('\n')}\n\nPlease update each product with its selected image URL. Skip products marked SKIP.`;
345
+
346
+ try {
347
+ await app.sendMessage({
348
+ role: 'user',
349
+ content: [{ type: 'text', text: message }],
350
+ });
351
+ setSubmitted(true);
352
+ } catch (err) {
353
+ setError(`Failed to send: ${err instanceof Error ? err.message : String(err)}`);
354
+ }
355
+ }, [app, pickerData, selections]);
356
+
357
+ if (connError) {
358
+ return (
359
+ <div style={styles.body}>
360
+ <div style={styles.error}>Connection failed: {connError.message}</div>
361
+ </div>
362
+ );
363
+ }
364
+ if (!isConnected) {
365
+ return (
366
+ <div style={styles.body}>
367
+ <span style={styles.status}>Connecting...</span>
368
+ </div>
369
+ );
370
+ }
371
+ if (!pickerData) {
372
+ return (
373
+ <div style={styles.body}>
374
+ <span style={styles.status}>Waiting for image candidates...</span>
375
+ </div>
376
+ );
377
+ }
378
+
379
+ const selectedCount = Object.values(selections).filter((v) => v !== null).length;
380
+ const total = pickerData.products.length;
381
+
382
+ return (
383
+ <div style={styles.body}>
384
+ <div style={styles.header}>
385
+ <h2 style={styles.title}>Select Product Images</h2>
386
+ {pickerData.batchLabel && (
387
+ <span style={styles.batchInfo}>{pickerData.batchLabel}</span>
388
+ )}
389
+ </div>
390
+
391
+ {pickerData.products.map((product, idx) => (
392
+ <ProductSection
393
+ key={product.productId}
394
+ product={product}
395
+ isLast={idx === pickerData.products.length - 1}
396
+ selection={selections[product.productId]}
397
+ onSelect={(imageUrl) => handleSelect(product.productId, imageUrl)}
398
+ onSkip={() => handleSkip(product.productId)}
399
+ />
400
+ ))}
401
+
402
+ <div style={styles.footer}>
403
+ {!submitted ? (
404
+ <button style={styles.btnPrimary} onClick={handleSave}>
405
+ Save Selections ({selectedCount}/{total})
406
+ </button>
407
+ ) : (
408
+ <span style={styles.status}>
409
+ Selections sent — click send in the chat to confirm.
410
+ </span>
411
+ )}
412
+ </div>
413
+
414
+ {error && <div style={styles.error}>{error}</div>}
415
+ </div>
416
+ );
417
+ }
418
+
419
+ // ============================================================================
420
+ // Mount
421
+ // ============================================================================
422
+
423
+ createRoot(document.getElementById('root')!).render(<ImagePickerApp />);
package/src/client.ts ADDED
@@ -0,0 +1,187 @@
1
+ interface ApiResponse<T = unknown> {
2
+ data?: T;
3
+ error?: { code: string; message: string; details?: unknown };
4
+ pagination?: { cursor: string | null; hasMore: boolean };
5
+ }
6
+
7
+ export class SwatchaClient {
8
+ private baseUrl: string;
9
+ private apiKey: string;
10
+
11
+ constructor(baseUrl: string, apiKey: string) {
12
+ this.baseUrl = baseUrl.replace(/\/$/, '');
13
+ this.apiKey = apiKey;
14
+ }
15
+
16
+ private async request<T>(
17
+ method: string,
18
+ path: string,
19
+ body?: unknown
20
+ ): Promise<ApiResponse<T>> {
21
+ const url = `${this.baseUrl}/api/v1${path}`;
22
+ const headers: Record<string, string> = {
23
+ Authorization: `Bearer ${this.apiKey}`,
24
+ 'Content-Type': 'application/json',
25
+ };
26
+
27
+ const response = await fetch(url, {
28
+ method,
29
+ headers,
30
+ body: body ? JSON.stringify(body) : undefined,
31
+ });
32
+
33
+ const json = (await response.json()) as ApiResponse<T>;
34
+
35
+ if (!response.ok) {
36
+ throw new Error(json.error?.message || `API error: ${response.status}`);
37
+ }
38
+
39
+ return json;
40
+ }
41
+
42
+ // Selections
43
+ async listSelections() {
44
+ return this.request('GET', '/selections');
45
+ }
46
+
47
+ async getSelection(id: string) {
48
+ return this.request('GET', `/selections/${id}`);
49
+ }
50
+
51
+ async createSelection(data: {
52
+ name: string;
53
+ clientId: string;
54
+ status?: string;
55
+ notes?: string;
56
+ siteAddress?: string;
57
+ }) {
58
+ return this.request('POST', '/selections', data);
59
+ }
60
+
61
+ async updateSelection(
62
+ id: string,
63
+ data: { name?: string; status?: string; notes?: string; siteAddress?: string }
64
+ ) {
65
+ return this.request('PATCH', `/selections/${id}`, data);
66
+ }
67
+
68
+ async deleteSelection(id: string) {
69
+ return this.request('DELETE', `/selections/${id}`);
70
+ }
71
+
72
+ // Selection Products
73
+ async listSelectionProducts(selectionId: string) {
74
+ return this.request('GET', `/selections/${selectionId}/products`);
75
+ }
76
+
77
+ async addProductToSelection(
78
+ selectionId: string,
79
+ data: {
80
+ productId: string;
81
+ quantity?: string;
82
+ location?: string;
83
+ locationId?: string;
84
+ notes?: string;
85
+ }
86
+ ) {
87
+ return this.request('POST', `/selections/${selectionId}/products`, data);
88
+ }
89
+
90
+ async updateSelectionProduct(
91
+ selectionId: string,
92
+ productId: string,
93
+ data: { quantity?: string; location?: string; locationId?: string; notes?: string }
94
+ ) {
95
+ return this.request(
96
+ 'PATCH',
97
+ `/selections/${selectionId}/products/${productId}`,
98
+ data
99
+ );
100
+ }
101
+
102
+ async removeProductFromSelection(selectionId: string, productId: string) {
103
+ return this.request(
104
+ 'DELETE',
105
+ `/selections/${selectionId}/products/${productId}`
106
+ );
107
+ }
108
+
109
+ // Products (Catalog)
110
+ async listProducts(query?: string) {
111
+ const qs = query ? `?q=${encodeURIComponent(query)}` : '';
112
+ return this.request('GET', `/products${qs}`);
113
+ }
114
+
115
+ async getProduct(id: string) {
116
+ return this.request('GET', `/products/${id}`);
117
+ }
118
+
119
+ async createProduct(data: {
120
+ name: string;
121
+ sku?: string;
122
+ price?: number;
123
+ supplierId?: string;
124
+ imageUrl?: string;
125
+ description?: string;
126
+ }) {
127
+ return this.request('POST', '/products', data);
128
+ }
129
+
130
+ async updateProduct(
131
+ id: string,
132
+ data: {
133
+ name?: string;
134
+ sku?: string;
135
+ price?: number;
136
+ supplierId?: string;
137
+ imageUrl?: string;
138
+ }
139
+ ) {
140
+ return this.request('PATCH', `/products/${id}`, data);
141
+ }
142
+
143
+ // Clients
144
+ async listClients() {
145
+ return this.request('GET', '/clients');
146
+ }
147
+
148
+ async createClient(data: { name: string }) {
149
+ return this.request('POST', '/clients', data);
150
+ }
151
+
152
+ // Suppliers
153
+ async listSuppliers() {
154
+ return this.request('GET', '/suppliers');
155
+ }
156
+
157
+ async createSupplier(data: { name: string; websiteUrl?: string }) {
158
+ return this.request('POST', '/suppliers', data);
159
+ }
160
+
161
+ // Locations
162
+ async listLocations() {
163
+ return this.request('GET', '/locations');
164
+ }
165
+
166
+ async createLocation(data: { name: string }) {
167
+ return this.request('POST', '/locations', data);
168
+ }
169
+
170
+ // Quota
171
+ async getQuota() {
172
+ return this.request('GET', '/quota');
173
+ }
174
+
175
+ // Image Search
176
+ async searchProductImages(productName: string, options?: {
177
+ supplier?: string;
178
+ supplierUrl?: string;
179
+ limit?: number;
180
+ }) {
181
+ const params = new URLSearchParams({ product: productName });
182
+ if (options?.supplier) params.append('supplier', options.supplier);
183
+ if (options?.supplierUrl) params.append('supplierUrl', options.supplierUrl);
184
+ if (options?.limit) params.append('limit', String(options.limit));
185
+ return this.request('GET', `/images/search?${params}`);
186
+ }
187
+ }