diff --git a/README.md b/README.md index 52f43f56..e60e6bad 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

- The eBay MCP server — give Claude, Cursor, and any AI assistant full access to eBay's Sell APIs. 379 tools for inventory, orders, marketing, and analytics, running locally with your own keys. + The eBay MCP server — give Claude, Cursor, and any AI assistant full access to eBay's Sell APIs. 380 tools for inventory, orders, marketing, and analytics, running locally with your own keys.

Unofficial, open-source project — not affiliated with, authorized, or endorsed by eBay Inc.

@@ -20,7 +20,7 @@

- 379 eBay API tools + 380 eBay API tools 100% eBay Sell API coverage Model Context Protocol compatible Over 1,000 passing tests @@ -33,7 +33,7 @@ --- -**eBay MCP** is a local [Model Context Protocol](https://modelcontextprotocol.io) server that connects AI assistants — [Claude Desktop](https://claude.ai/download), [Claude Code](https://code.claude.com/docs/en/overview), [Cursor](https://cursor.com/), [Cline](https://cline.bot/), [Windsurf](https://windsurf.com/), [Zed](https://zed.dev/), [Continue.dev](https://docs.continue.dev/), [Roo Code](https://roocode.com/), and [Amazon Q Developer](https://aws.amazon.com/q/developer/) — directly to **[eBay's Sell APIs](https://developer.ebay.com/api-docs/sell/static/overview.html)**. It exposes **379 tools** spanning **100% of the eBay Sell, Commerce and Developer APIs it tracks** (356 unique endpoints) for inventory management, order fulfillment, promoted-listings marketing, analytics, and developer tooling. Everything runs on your machine over STDIO or local HTTP — **no cloud relay**, and your eBay credentials never leave your computer. +**eBay MCP** is a local [Model Context Protocol](https://modelcontextprotocol.io) server that connects AI assistants — [Claude Desktop](https://claude.ai/download), [Claude Code](https://code.claude.com/docs/en/overview), [Cursor](https://cursor.com/), [Cline](https://cline.bot/), [Windsurf](https://windsurf.com/), [Zed](https://zed.dev/), [Continue.dev](https://docs.continue.dev/), [Roo Code](https://roocode.com/), and [Amazon Q Developer](https://aws.amazon.com/q/developer/) — directly to **[eBay's Sell APIs](https://developer.ebay.com/api-docs/sell/static/overview.html)**. It exposes **380 tools** spanning **100% of the eBay Sell, Commerce and Developer APIs it tracks** (356 unique endpoints) for inventory management, order fulfillment, promoted-listings marketing, analytics, and developer tooling. Everything runs on your machine over STDIO or local HTTP — **no cloud relay**, and your eBay credentials never leave your computer. > **Disclaimer:** Unofficial, third-party project — **not affiliated with or endorsed by eBay Inc.** Provided "as is" without warranty. You are responsible for complying with [eBay's API License Agreement](https://developer.ebay.com/join/api-license-agreement) and [data-handling requirements](https://developer.ebay.com/api-docs/static/data-handling-update.html), keeping your credentials secure, and staying within rate limits. Test in sandbox before production. See [LICENSE](LICENSE). @@ -59,7 +59,7 @@ ## Features -- **379 eBay API tools** — 100% coverage of the eBay Sell APIs across inventory, orders, marketing, analytics, metadata, taxonomy, and developer tooling. +- **380 eBay API tools** — 100% coverage of the eBay Sell APIs across inventory, orders, marketing, analytics, metadata, taxonomy, and developer tooling. - **9 AI clients, auto-configured** — Claude Desktop, Cursor, Zed, Cline, Continue.dev, Windsurf, Roo Code, Claude Code CLI, and Amazon Q Developer. - **OAuth 2.0 built in** — full user-token management with automatic refresh, and smart fallback from user tokens (10k–50k req/day) to client credentials (1k req/day). - **Resilient by default** — automatic retry with exponential backoff on `429` rate limits, and consistent, loud error surfacing. @@ -154,6 +154,7 @@ files through eBay's Media API and return what the Inventory API needs: | Tool | What it does | | --- | --- | | `ebay_upload_images` | Uploads pictures to eBay Picture Services (`createImageFromFile`) and returns the EPS URLs in order for `product.imageUrls` | +| `ebay_upload_image_data` | Same as `ebay_upload_images`, but takes the pictures inline as base64, so an assistant can upload photos that are not on the server's machine (for example images attached to a chat). Needs no media directory | | `ebay_upload_video` | Runs the video lifecycle (`createVideo` → `uploadVideo` → `getVideo`) and returns the `videoId` for `product.videoIds` | | `ebay_get_video` | Re-checks a video that was still `PROCESSING` | | `ebay_attach_media_to_inventory_item` | Reads the item, uploads the files, and rewrites only `product.imageUrls` / `product.videoIds` — never publishes, and leaves the item untouched if any upload fails unless `allowPartial` is set | @@ -281,7 +282,7 @@ Both talk to the same eBay endpoints — the difference is everything you'd othe | Input validation | Zod schemas + TypeScript types on every tool | None — you validate your own payloads | | Setup | One wizard (`npm run setup`) | Per-call auth, headers, and marketplace wiring | | AI client support | 9 clients auto-configured | Not applicable | -| API coverage | 379 tools across 100% of the Sell APIs, ready to call | Build each request from the docs | +| API coverage | 380 tools across 100% of the Sell APIs, ready to call | Build each request from the docs | | Hosting | Runs locally, no cloud relay | Your own infrastructure | ## One-click AI setup @@ -463,9 +464,9 @@ Auto-configured by `npm run setup`. Requires [Node.js](https://nodejs.org/en) ## Available tools

-379 tools by category (100% Sell API coverage) +380 tools by category (100% Sell API coverage) -**379 tools**, 100% Sell API coverage, organized by category. Each link points to the tool definitions and handlers in [`src/tools/categories/`](src/tools/categories/): +**380 tools**, 100% Sell API coverage, organized by category. Each link points to the tool definitions and handlers in [`src/tools/categories/`](src/tools/categories/): | Category | What you can do | | --- | --- | @@ -553,7 +554,7 @@ Common tasks, phrased as you'd ask your AI assistant:
What is the eBay MCP server? -A local [Model Context Protocol](https://modelcontextprotocol.io) server that exposes **379 tools** covering **100% of the eBay Sell, Commerce and Developer APIs it tracks** (356 endpoints) to AI assistants — inventory, order fulfillment, marketing, analytics, and developer tools. +A local [Model Context Protocol](https://modelcontextprotocol.io) server that exposes **380 tools** covering **100% of the eBay Sell, Commerce and Developer APIs it tracks** (356 endpoints) to AI assistants — inventory, order fulfillment, marketing, analytics, and developer tools.
@@ -588,7 +589,7 @@ Interactive [MCP Apps](#interactive-ui-mcp-apps) views only appear on hosts that
How many eBay APIs and tools does it cover? -379 tools across 356 unique endpoints — 100% of the eBay Sell, Commerce and Developer APIs it tracks. +380 tools across 356 unique endpoints — 100% of the eBay Sell, Commerce and Developer APIs it tracks.
diff --git a/llms.txt b/llms.txt index 304fb361..44405979 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # ebay-mcp -> Local MCP server that exposes 379 tools covering 100% of the eBay Sell, Commerce and Developer APIs it tracks to AI coding assistants (Claude, Cursor, Windsurf, and any MCP-compatible client). Runs entirely on your machine with no cloud relay. +> Local MCP server that exposes 380 tools covering 100% of the eBay Sell, Commerce and Developer APIs it tracks to AI coding assistants (Claude, Cursor, Windsurf, and any MCP-compatible client). Runs entirely on your machine with no cloud relay. Built with TypeScript/Node.js, the MCP SDK, Zod tool-input schemas, Effect for typed async errors, and OpenAPI-generated eBay types. Supports OAuth 2.0, configurable tool exposure, MCP Apps read-only views, and the eBay Trading API (legacy XML). @@ -56,7 +56,7 @@ Tool definitions and handlers are co-located per family in `src/tools/categories ### What is the eBay MCP server? -A local Model Context Protocol (MCP) server that exposes 379 tools covering 100% of the eBay Sell, Commerce and Developer APIs it tracks (356 endpoints) to AI assistants such as Claude, Cursor, and Cline. +A local Model Context Protocol (MCP) server that exposes 380 tools covering 100% of the eBay Sell, Commerce and Developer APIs it tracks (356 endpoints) to AI assistants such as Claude, Cursor, and Cline. ### Is this an official eBay product? @@ -72,7 +72,7 @@ Yes. It works with Claude Desktop and Claude Code out of the box, with Cursor an ### How many eBay APIs and tools does it cover? -379 tools across 356 unique endpoints — 100% of the eBay Sell, Commerce and Developer APIs it tracks. +380 tools across 356 unique endpoints — 100% of the eBay Sell, Commerce and Developer APIs it tracks. ### Is it free and open source? diff --git a/package.json b/package.json index 97d5374a..8cdc5029 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ebay-mcp", "version": "1.18.0", - "description": "Model Context Protocol (MCP) server that exposes 100% of the eBay Sell, Commerce and Developer APIs it tracks (379 tools across 356 endpoints) to AI assistants like Claude, Cursor, and Cline, covering inventory, order fulfillment, marketing, analytics, and developer tools with OAuth authentication and refresh-token support.", + "description": "Model Context Protocol (MCP) server that exposes 100% of the eBay Sell, Commerce and Developer APIs it tracks (380 tools across 356 endpoints) to AI assistants like Claude, Cursor, and Cline, covering inventory, order fulfillment, marketing, analytics, and developer tools with OAuth authentication and refresh-token support.", "type": "module", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/src/schemas/inventory-management/media.ts b/src/schemas/inventory-management/media.ts index a9c8b449..b2334592 100644 --- a/src/schemas/inventory-management/media.ts +++ b/src/schemas/inventory-management/media.ts @@ -35,6 +35,30 @@ export const uploadImagesInputSchema = z.object({ ), }); +/** Validates the ebay_upload_image_data input. */ +export const uploadImageDataInputSchema = z.object({ + images: z + .array( + z.object({ + data: z + .string() + .min(1) + .describe( + 'Image bytes as base64, optionally prefixed with a data URL header (data:image/jpeg;base64,...). JPG, PNG, GIF, BMP, TIFF, WEBP, AVIF or HEIC, up to 12 MB decoded', + ), + fileName: z + .string() + .min(1) + .max(100) + .optional() + .describe('File name sent to eBay; defaults to image- with the detected extension'), + }), + ) + .min(1) + .max(24) + .describe('Images in listing order'), +}); + /** Validates the ebay_upload_video input. */ export const uploadVideoInputSchema = z.object({ path: mediaReferenceSchema.describe( diff --git a/src/tools/categories/media.ts b/src/tools/categories/media.ts index 2e849abf..017f066d 100644 --- a/src/tools/categories/media.ts +++ b/src/tools/categories/media.ts @@ -10,6 +10,7 @@ import { getMediaAccessConfig } from '@/config/mediaAccess.js'; import { attachMediaInputSchema, attachMediaOutputSchema, + uploadImageDataInputSchema, uploadImagesInputSchema, uploadImagesOutputSchema, uploadVideoInputSchema, @@ -19,6 +20,7 @@ import { import { defineTool } from '@/tools/defineTool.js'; import type { ToolEntry } from '@/tools/registry.js'; import type { OutputArgs } from '@/tools/types.js'; +import { decodeInlineImages } from '@/utils/inlineMedia.js'; import { loadLocalMedia, loadLocalMediaList } from '@/utils/localMedia.js'; import { Effect } from 'effect'; import { zodToJsonSchema } from 'zod-to-json-schema'; @@ -144,6 +146,23 @@ export const mediaEntries: ToolEntry[] = [ ), ), }), + defineTool({ + name: 'ebay_upload_image_data', + description: + 'Upload pictures passed inline as base64 to eBay Picture Services and return the EPS image URLs in the same order, ready for product.imageUrls or Trading API PictureDetails.PictureURL. Use it when the images are not on the machine running this server (for example photos attached to a chat). The image type is detected from the file signature. Uses the Media API (createImageFromFile); requires sell.inventory. Unused images expire after a while; they become permanent once a listing uses them.', + inputSchema: uploadImageDataInputSchema.shape, + outputSchema: zodToJsonSchema(uploadImagesOutputSchema, { + name: 'UploadImageDataResponse', + $refStrategy: 'none', + }) as OutputArgs, + annotations: { readOnlyHint: false }, + handler: (api, args) => + Effect.runPromise( + decodeInlineImages(args.images).pipe( + Effect.flatMap((files) => api.media.uploadImageFiles(files)), + ), + ), + }), defineTool({ name: 'ebay_upload_video', description: `Upload a local video through the Media API lifecycle (createVideo → uploadVideo → getVideo) and return the videoId with its processing status. Waits up to waitForProcessingSeconds (default 120) for status LIVE; PROCESSING means check again with ebay_get_video, BLOCKED or PROCESSING_FAILED explain why eBay rejected it (statusMessage). MP4/MOV up to 150 MB; one video per listing.\n\n${MEDIA_ACCESS_NOTE}`, diff --git a/src/utils/inlineMedia.ts b/src/utils/inlineMedia.ts new file mode 100644 index 00000000..c677a52c --- /dev/null +++ b/src/utils/inlineMedia.ts @@ -0,0 +1,82 @@ +import type { MediaUpload } from '@/api/listing-management/media.js'; +import { Effect } from 'effect'; +import { LocalMediaError, MAX_IMAGE_BYTES, sniffMediaType } from './localMedia.js'; + +/** One image passed inline as base64 instead of as a local file path. */ +export interface InlineImageInput { + /** Base64 bytes, optionally prefixed with a `data:;base64,` header. */ + readonly data: string; + /** Optional file name sent to eBay. */ + readonly fileName?: string; +} + +const EXTENSION_BY_MIME: Readonly> = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/bmp': 'bmp', + 'image/tiff': 'tiff', + 'image/webp': 'webp', + 'image/avif': 'avif', + 'image/heic': 'heic', +}; + +const DATA_URL_PREFIX = /^data:[^;,]*;base64,/i; +const BASE64_BODY = /^[A-Za-z0-9+/]*={0,2}$/; + +/** + * Decodes and validates one inline image. The bytes must decode cleanly from + * base64, stay within the eBay Picture Services size limit and carry the file + * signature of a supported image type; the declared data URL type is ignored in + * favour of the signature. + */ +export const decodeInlineImage = ( + input: InlineImageInput, + index: number, +): Effect.Effect => + Effect.gen(function* () { + const source = input.fileName ?? `inline image ${index + 1}`; + const body = input.data.replace(DATA_URL_PREFIX, '').replace(/\s+/g, ''); + if (body.length === 0 || !BASE64_BODY.test(body)) { + return yield* Effect.fail( + new LocalMediaError({ source, message: `${source} is not valid base64` }), + ); + } + + const bytes = Buffer.from(body, 'base64'); + if (bytes.length === 0) { + return yield* Effect.fail(new LocalMediaError({ source, message: `${source} is empty` })); + } + if (bytes.length > MAX_IMAGE_BYTES) { + return yield* Effect.fail( + new LocalMediaError({ + source, + message: `${source} is ${bytes.length} bytes; eBay accepts images up to ${MAX_IMAGE_BYTES} bytes`, + }), + ); + } + + const mimeType = sniffMediaType(bytes); + const extension = mimeType ? EXTENSION_BY_MIME[mimeType] : undefined; + if (!mimeType || !extension) { + return yield* Effect.fail( + new LocalMediaError({ + source, + message: `${source} is not a supported image (JPG, PNG, GIF, BMP, TIFF, WEBP, AVIF or HEIC)`, + }), + ); + } + + return { + source, + fileName: input.fileName ?? `image-${index + 1}.${extension}`, + mimeType, + bytes, + }; + }); + +/** Decodes a list of inline images, failing on the first invalid one. */ +export const decodeInlineImages = ( + inputs: readonly InlineImageInput[], +): Effect.Effect => + Effect.forEach(inputs, (input, index) => decodeInlineImage(input, index)); diff --git a/tests/integration/tools/mediaTools.test.ts b/tests/integration/tools/mediaTools.test.ts index 4312c6f6..e4d37035 100644 --- a/tests/integration/tools/mediaTools.test.ts +++ b/tests/integration/tools/mediaTools.test.ts @@ -7,7 +7,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { EbaySellerApi } from '@/api/ebaySellerApi.js'; import { createEbayMcpRuntime, type EbayMcpRuntime } from '@/mcp/runtime.js'; import type { EbayConfig } from '@/types/ebay.js'; -import { createMediaFixture, type MediaFixture } from '@tests/helpers/mediaFixtures.js'; +import { + createMediaFixture, + JPEG_BYTES, + type MediaFixture, + PNG_BYTES, +} from '@tests/helpers/mediaFixtures.js'; import { Effect } from 'effect'; import nock from 'nock'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -106,6 +111,7 @@ describe('media tools registration', () => { expect(names).toEqual( expect.arrayContaining([ 'ebay_upload_images', + 'ebay_upload_image_data', 'ebay_upload_video', 'ebay_get_video', 'ebay_attach_media_to_inventory_item', @@ -174,6 +180,54 @@ describe('ebay_upload_images', () => { }); }); +describe('ebay_upload_image_data', () => { + it('uploads inline base64 pictures in order without any media directory configured', async () => { + delete process.env.EBAY_MCP_MEDIA_ROOT; + delete process.env.EBAY_MCP_MEDIA_DIRS; + const first = mockImageUpload('https://i.ebayimg.com/inline-front.jpg'); + const second = mockImageUpload('https://i.ebayimg.com/inline-back.png'); + + const uploadResult = await callTool('ebay_upload_image_data', { + images: [ + { data: JPEG_BYTES.toString('base64'), fileName: 'front.jpg' }, + { data: `data:image/png;base64,${PNG_BYTES.toString('base64')}` }, + ], + }); + + expect(uploadResult.isError).toBe(false); + expect(uploadResult.payload).toEqual({ + images: [ + { + source: 'front.jpg', + imageId: expect.stringMatching(/^IMG-/), + imageUrl: 'https://i.ebayimg.com/inline-front.jpg', + expirationDate: '2026-09-30T00:00:00.000Z', + }, + { + source: 'inline image 2', + imageId: expect.stringMatching(/^IMG-/), + imageUrl: 'https://i.ebayimg.com/inline-back.png', + expirationDate: '2026-09-30T00:00:00.000Z', + }, + ], + }); + expect(first.isDone()).toBe(true); + expect(second.isDone()).toBe(true); + }); + + it('rejects data that is not an image without contacting eBay', async () => { + const upload = mockImageUpload('https://i.ebayimg.com/never-inline.jpg'); + + const rejected = await callTool('ebay_upload_image_data', { + images: [{ data: Buffer.from('hello world').toString('base64') }], + }); + + expect(rejected.isError).toBe(true); + expect(rejected.text).toContain('not a supported image'); + expect(upload.isDone()).toBe(false); + }); +}); + describe('ebay_upload_video and ebay_get_video', () => { it('runs the create → upload → poll lifecycle and returns the video ID', async () => { const create = nock(MEDIA_HOST) diff --git a/tests/unit/utils/inlineMedia.test.ts b/tests/unit/utils/inlineMedia.test.ts new file mode 100644 index 00000000..7af08606 --- /dev/null +++ b/tests/unit/utils/inlineMedia.test.ts @@ -0,0 +1,87 @@ +import { decodeInlineImage, decodeInlineImages } from '@/utils/inlineMedia.js'; +import { MAX_IMAGE_BYTES } from '@/utils/localMedia.js'; +import { isoHeader, JPEG_BYTES, MP4_BYTES, PNG_BYTES } from '@tests/helpers/mediaFixtures.js'; +import { Effect } from 'effect'; +import { describe, expect, it } from 'vitest'; + +const failureOf = (program: Effect.Effect): Promise => + Effect.runPromise(Effect.flip(program).pipe(Effect.map((error) => error.message))); + +describe('decodeInlineImage', () => { + it('decodes plain base64 and detects the type from the signature', async () => { + const upload = await Effect.runPromise( + decodeInlineImage({ data: JPEG_BYTES.toString('base64') }, 0), + ); + + expect(upload).toEqual({ + source: 'inline image 1', + fileName: 'image-1.jpg', + mimeType: 'image/jpeg', + bytes: JPEG_BYTES, + }); + }); + + it('strips a data URL header and keeps a caller file name', async () => { + const upload = await Effect.runPromise( + decodeInlineImage( + { data: `data:image/png;base64,${PNG_BYTES.toString('base64')}`, fileName: 'back.png' }, + 3, + ), + ); + + expect(upload.source).toBe('back.png'); + expect(upload.fileName).toBe('back.png'); + expect(upload.mimeType).toBe('image/png'); + expect(Buffer.from(upload.bytes)).toEqual(PNG_BYTES); + }); + + it('trusts the signature over a mismatching data URL type', async () => { + const upload = await Effect.runPromise( + decodeInlineImage({ data: `data:image/png;base64,${JPEG_BYTES.toString('base64')}` }, 0), + ); + + expect(upload.mimeType).toBe('image/jpeg'); + }); + + it('accepts HEIC photos as taken on an iPhone', async () => { + const upload = await Effect.runPromise( + decodeInlineImage({ data: isoHeader('heic').toString('base64') }, 1), + ); + + expect(upload.mimeType).toBe('image/heic'); + expect(upload.fileName).toBe('image-2.heic'); + }); + + it('rejects text that is not base64', async () => { + await expect(failureOf(decodeInlineImage({ data: 'not base64!' }, 0))).resolves.toContain( + 'not valid base64', + ); + }); + + it('rejects data that is not a supported image', async () => { + await expect( + failureOf(decodeInlineImage({ data: MP4_BYTES.toString('base64') }, 0)), + ).resolves.toContain('not a supported image'); + }); + + it('rejects images above the eBay Picture Services limit', async () => { + const tooLarge = Buffer.concat([JPEG_BYTES, Buffer.alloc(MAX_IMAGE_BYTES, 0)]); + + await expect( + failureOf(decodeInlineImage({ data: tooLarge.toString('base64') }, 0)), + ).resolves.toContain(`up to ${MAX_IMAGE_BYTES} bytes`); + }); +}); + +describe('decodeInlineImages', () => { + it('keeps input order and numbers default file names', async () => { + const uploads = await Effect.runPromise( + decodeInlineImages([ + { data: JPEG_BYTES.toString('base64') }, + { data: PNG_BYTES.toString('base64') }, + ]), + ); + + expect(uploads.map((upload) => upload.fileName)).toEqual(['image-1.jpg', 'image-2.png']); + }); +});