# Plytix MCP Server

A **lightweight, stateless Model Context Protocol (MCP) server** that provides AI assistants with live access to Plytix PIM (Product Information Management) data. This server enables AI tools like Claude Desktop, Claude mobile app, and other MCP clients to search, look up, and retrieve product information directly from the Plytix API.

> **Note:** This is a stateless, live API tool for read and write operations. For sync, caching, or ETL workflows, pair it with a separate persistence layer of your choosing.

## Features

- **51 MCP tools via stdio and 46 via the remote worker**
- **Smart product lookup** with automatic identifier detection (SKU, MPN, GTIN, label)
- **Family & inheritance tracking** with overwritten_attributes support
- **Schema discovery** for attributes and search filters
- **Automatic authentication** with token refresh
- **Rate limit handling** — proactive pacing to the account's advertised window plus jittered 429/5xx backoff
- **Zero persistence** — stateless, no database required
- **Remote server support** — Deploy to Cloudflare Workers for mobile/web access

## Installation

### Prerequisites

- **Node.js 20+**
- **Plytix PIM account** with API access

### Setup

1. **Clone and install:**
```bash
git clone https://github.com/Supplyline/plytix-mcp.git
cd plytix-mcp
npm install
```

2. **Configure credentials:**
```bash
cp .env.example .env
# Edit .env with your Plytix API credentials
```

3. **Build the project:**
```bash
npm run build
```

## Usage

### With Claude Desktop

Add to your Claude Desktop MCP configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):

```json
{
  "mcpServers": {
    "plytix": {
      "command": "node",
      "args": ["/path/to/plytix-mcp/dist/index.js"],
      "env": {
        "PLYTIX_API_KEY": "your_api_key_here",
        "PLYTIX_API_PASSWORD": "your_api_password_here"
      }
    }
  }
}
```

### Standalone

```bash
npm start          # Production mode
npm run dev        # Development with hot reload
```

## Remote Server (Cloudflare Workers)

Deploy as a remote MCP server for access from Claude mobile app, web clients, or shared team access.

### Quick Deploy

```bash
# Install dependencies (includes wrangler)
npm install

# Login to Cloudflare
wrangler login

# Deploy to Cloudflare Workers
npm run deploy
```

### Using the Remote Server

Once deployed, connect from any MCP client using the deployed URL and your Plytix credentials:

```bash
# With Claude Desktop (using mcp-remote)
npx mcp-remote https://plytix-mcp.your-subdomain.workers.dev/mcp \
  --header "X-Plytix-API-Key: YOUR_API_KEY" \
  --header "X-Plytix-API-Password: YOUR_API_PASSWORD"
```

The remote worker exposes 48 tools. It intentionally omits local-only utilities and filesystem tools: `identifier_detect`, `identifier_normalize`, `match_score`, `products_batch_update_manifest`, and `products_batch_export_to_file`.

### Protocol versions

The worker is **dual-era**: it serves both the stateless `2026-07-28` revision and the
older handshake-based revisions on the same endpoint, choosing per request.

| Client sends | Served as |
|---|---|
| `_meta` with `io.modelcontextprotocol/protocolVersion`, or an `MCP-Protocol-Version` header naming a non-legacy version | `2026-07-28` — mirrored-header validation, `server/discover`, `resultType`/`serverInfo` on results, spec HTTP status codes |
| `initialize` handshake (`2025-11-25` … `2024-11-05`) | Legacy — responses unchanged from previous releases |

A client newer than this server receives `-32022` with the supported list and can
renegotiate down. The `2026-07-28` transport dropped the standalone `GET` stream and
`DELETE` teardown, so both now answer `405`; `Mcp-Session-Id` is ignored rather than
echoed.

The stdio server still speaks the legacy handshake — it delegates the protocol to
`@modelcontextprotocol/sdk`, which has not yet shipped `2026-07-28`.

### Local Development

```bash
npm run dev:worker         # Start local worker at localhost:8787
npm run test:worker        # Test the worker endpoints
```

For detailed setup instructions, see [docs/remote-setup.md](docs/remote-setup.md).

## Available Tools

### Product Tools

| Tool | Description |
|------|-------------|
| `products_lookup` | Smart lookup by any identifier (auto-detects ID, SKU, MPN, GTIN, label) |
| `products_get` | Get single product by ID with full details and `overwritten_attributes` |
| `products_get_full` | Get one product with related family, variants, categories, and assets |
| `products_search` | Advanced search with filters, pagination, and sorting |
| `products_find` | Simple multi-criteria search (SKU, MPN, MNO, GTIN, label, fuzzy) |
| `products_batch_export` | Capped inline product snapshot by search, SKU, or product ID |
| `products_batch_export_to_file` | Stdio-only JSONL/NDJSON product export under `PLYTIX_MCP_EXPORT_DIR` |
| `products_create` | Create a new product |
| `products_update` | Partial update to product fields/attributes |
| `products_batch_update` | Apply a small guarded product-update batch |
| `products_batch_update_manifest` | Stdio-only guarded product updates from a local JSON manifest |
| `products_bulk_update` | Submit up to 1,000 updates as ONE async Plytix bulk job and wait for it to settle (no drift guards; `pending` + `job_id` if the wait budget runs out; stdio also accepts `manifest_path`) |
| `products_bulk_status` | Poll a bulk job by `job_id`; pass `expected_total` so completion can be confirmed (Plytix reports "Finished" before the summary is populated) |
| `products_assign_family` | Assign or unassign a product family |
| `products_set_attribute` | Atomic set of one attribute value |
| `products_clear_attribute` | Atomic clear of one attribute value |

### Family Tools

| Tool | Description |
|------|-------------|
| `families_list` | List or search product families |
| `families_get` | Get single family with linked attributes |
| `families_create` | Create a new product family |
| `families_link_attribute` | Link one or more attributes to a family |
| `families_unlink_attribute` | Unlink one or more attributes from a family |
| `families_list_attributes` | List attributes directly linked to a family |
| `families_list_all_attributes` | List direct and inherited family attributes |

### Attribute & Filter Tools

| Tool | Description |
|------|-------------|
| `attributes_list` | List all attributes (system + custom) with types and options |
| `attributes_get` | Get full metadata for one attribute label |
| `attributes_get_options` | Get allowed values for a selectable attribute |
| `attributes_filters` | Deprecated alias for product filter discovery |
| `products_filters` | Get product search filter metadata |
| `assets_filters` | Get asset search filter metadata |
| `relationships_filters` | Get relationship search filter metadata |

### Asset Tools

| Tool | Description |
|------|-------------|
| `assets_get` | Get one asset by ID |
| `assets_search` | Search account assets |
| `assets_update` | Update asset metadata (`filename`, `categories`) |
| `assets_list` | List assets (images, videos, documents) linked to a product |
| `assets_link` | Link an asset to a product |
| `assets_unlink` | Unlink an asset from a product |

### Category Tools

| Tool | Description |
|------|-------------|
| `categories_search` | Search existing categories |
| `categories_list` | List categories associated with a product |
| `categories_link` | Link a category to a product |
| `categories_unlink` | Unlink a category from a product |

### Variant Tools

| Tool | Description |
|------|-------------|
| `variants_create` | Create a variant under a parent product |
| `variants_link` | Link an existing product as a variant |
| `variants_unlink` | Unlink a variant from its parent without deleting it |
| `variants_list` | List variants for a product |
| `variants_resync` | Reset variant attributes to inherit parent values |

### Relationship Tools

| Tool | Description |
|------|-------------|
| `relationships_get` | Get a relationship definition by ID |
| `relationships_search` | Search relationship definitions |
| `relationships_link_product` | Link one related product row in a relationship |
| `relationships_unlink_product` | Unlink one related product row in a relationship |
| `relationships_set_quantity` | Update quantity for one related product row |

### Identifier Utilities (Stdio Only)

| Tool | Description |
|------|-------------|
| `identifier_detect` | Detect identifier type from raw value |
| `identifier_normalize` | Normalize identifier for matching |
| `match_score` | Score identifier-product match confidence |

The remote worker exposes every tool above except the three identifier utilities in the final section and the two stdio-only filesystem tools: `products_batch_update_manifest` and `products_batch_export_to_file`.

## Smart Lookup System

The `products_lookup` tool automatically detects identifier types and uses staged search strategies:

**Detection priority:**
1. MongoDB ObjectId (24-char hex) → `id` (confidence: 1.0)
2. GTIN (8/12/13/14 digits) → `gtin` (confidence: 0.95)
3. Contains spaces → `label` (confidence: 0.9)
4. Dashed alphanumeric → `mpn` (confidence: 0.8)
5. Alphanumeric with separators → `sku` (confidence: 0.7)

**Search strategies (in order):**
1. Direct ID lookup (if detected as ID)
2. Exact field matches (SKU, GTIN, MPN, MNO)
3. Text search across multiple fields
4. Broad LIKE search (last resort)

**Example:**
```
Input: "LMI-PD-123"
→ Detected as: sku (confidence: 0.7)
→ Tries: sku_eq, mpn fields, text_search, broad_like
→ Returns: best match with confidence score
```

## Inheritance Tracking

Products return an `overwritten_attributes` array listing which attributes are explicitly set (not inherited from family). Use this with:

- `product_family_id` — The family this product belongs to
- `families_get` — Retrieve family-level default values
- Compare to determine inherited vs overwritten values

## Configuration

### Environment Variables

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `PLYTIX_API_KEY` | ✅ | — | Your Plytix API key |
| `PLYTIX_API_PASSWORD` | ✅ | — | Your Plytix API password |
| `PLYTIX_API_BASE` | ❌ | `https://pim.plytix.com` | Plytix API base URL |
| `PLYTIX_AUTH_URL` | ❌ | `https://auth.plytix.com/auth/api/get-token` | Auth endpoint |
| `PLYTIX_MPN_LABELS` | ❌ | `["attributes.mpn"]` | JSON array of MPN attribute labels |
| `PLYTIX_MNO_LABELS` | ❌ | `["attributes.model_no"]` | JSON array of MNO attribute labels |
| `PLYTIX_MCP_EXPORT_DIR` | ❌ | — | Required only for `products_batch_export_to_file`; limits file exports to this directory |
| `PLYTIX_MCP_MAX_DELETES` | ❌ | `3` | Deletes one server process will perform before refusing further ones. `0` disables deletes entirely. See [Delete safety gate](#delete-safety-gate) |

### Delete safety gate

`src/safety.ts` provides a two-call gate for destructive tools. A caller first
invokes the tool with `dry_run: true` and gets back a preview of what would be
removed plus a `confirm_token`; executing requires calling again with that
token. Tokens are single-use, scoped to one tool and one target, and expire
after 5 minutes. A per-process cap (`PLYTIX_MCP_MAX_DELETES`, default 3) bounds
how much any one session can remove.

**What this does and does not protect against.** The token goes back to the
calling agent, not to a human — an autonomous agent can read it from the
dry-run response and immediately call again. So the gate is a forced pause with
a visible preview and a hard ceiling, not a human confirmation step. It stops a
runaway loop from clearing a catalog; it does not stop an agent that has decided
to delete something.

Genuine human confirmation needs the MCP `elicitInput` capability, where the
server asks the client to prompt the user. A future revision can elicit when the
client advertises that capability and fall back to this token flow otherwise.

## Development

### Scripts

| Command | Description |
|---------|-------------|
| `npm run dev` | Development server with hot reload (stdio) |
| `npm run dev:worker` | Development server for Cloudflare Workers |
| `npm run build` | Build TypeScript to JavaScript |
| `npm run deploy` | Deploy to Cloudflare Workers |
| `npm start` | Start production server (stdio) |
| `npm test` | Run unit tests (vitest) |
| `npm run test:watch` | Run tests in watch mode |
| `npm run test:mcp` | Test MCP protocol handshake |
| `npm run test:worker` | Test worker endpoints |
| `npm run test:all` | Build + unit + integration + MCP tests |
| `npm run typecheck` | Type check without building |

### Architecture

```
src/
  index.ts              # MCP server entry point (stdio transport)
  worker.ts             # Cloudflare Worker entry point (HTTP transport)
  client.ts             # Plytix API client with auth & rate limiting
  worker-client.ts      # Worker-compatible client (BYOK credentials)
  worker-lookup.ts      # Worker-compatible smart lookup
  types.ts              # TypeScript types
  lookup/
    identifier.ts       # Identifier type detection
    lookup.ts           # Smart lookup with staged search
  tools/
    products.ts         # Product tools (lookup, get, search, find, write ops)
    families.ts         # Family tools (list, get, create, attribute membership)
    attributes.ts       # Attribute metadata + filter discovery tools
    product-attributes.ts # Atomic product attribute write tools
    assets.ts           # Asset get/search/update + product asset link tools
    categories.ts       # Category search + product category link tools
    variants.ts         # Variant lifecycle tools
    relationships.ts    # Relationship discovery + product relationship write tools
  extensions/           # Optional deployment-specific customizations
wrangler.toml           # Cloudflare Workers configuration
docs/
  remote-setup.md       # Remote server setup guide
```

### Design Principles

This MCP server is intentionally **stateless and lightweight**:

- **No database** — All queries go directly to Plytix API
- **No sync/caching layer** — Fresh data on every request
- **No background jobs** — Request/response only
- **Ephemeral in-memory cache** — Brief (60s) request deduplication, cleared on restart

For ETL, sync, or persistent caching needs, pair this server with a separate persistence layer.

## License

MIT License — see [LICENSE](LICENSE) file for details.

## Support

- **Issues**: [GitHub Issues](https://github.com/Supplyline/plytix-mcp/issues)
- **Plytix API**: [Plytix Documentation](https://docs.plytix.com/)
- **MCP Protocol**: [Model Context Protocol](https://modelcontextprotocol.io/)
