Add first-party Etsy MCP server for shop listings, orders and messages

Expose php artisan etsy:mcp as JSON-RPC stdio next to laravel-boost, reuse the stored HickLab3D shop connection, create listings as drafts only, and skip Etsy v3 conversation send because that API does not exist.
This commit is contained in:
2026-11-07 15:54:05 +00:00
parent ab1fdb961f
commit 9b13640571
11 changed files with 1294 additions and 19 deletions
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Services\EtsyMcpServer;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[Signature('etsy:mcp')]
#[Description('Run the HickLab3D Etsy MCP server over JSON-RPC stdio')]
class EtsyMcp extends Command
{
public function handle(EtsyMcpServer $server): int
{
ini_set('display_errors', 'stderr');
$this->output->setDecorated(false);
$input = $this->input->getStream() ?? STDIN;
while (! feof($input)) {
$line = fgets($input);
if ($line === false) {
break;
}
$response = $server->handleLine($line);
if ($response !== null) {
$this->output->write($response.PHP_EOL, false, OutputInterface::OUTPUT_RAW);
}
}
return self::SUCCESS;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class EtsyConnection extends Model
{
protected $fillable = ['shop_id', 'shop_name', 'access_token', 'refresh_token', 'expires_at'];
protected $fillable = ['shop_id', 'shop_name', 'access_token', 'refresh_token', 'expires_at', 'scopes'];
protected $hidden = ['access_token', 'refresh_token'];
+488 -13
View File
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\EtsyAppCredential;
use App\Models\EtsyConnection;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
@@ -11,6 +12,12 @@ class Etsy
{
private const API_URL = 'https://api.etsy.com/v3';
/** @var list<string> */
public const REQUIRED_SCOPES = ['listings_r', 'listings_w', 'transactions_r', 'transactions_w', 'shops_r'];
/** @var list<string> */
public const LISTING_STATES = ['draft', 'active', 'inactive', 'expired', 'sold_out'];
public function __construct(private IntegrationCache $cache) {}
/** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */
@@ -32,7 +39,7 @@ class Etsy
return 'https://www.etsy.com/oauth/connect?'.http_build_query([
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(),
'scope' => 'transactions_r listings_r', 'client_id' => $this->credentials()['keystring'],
'scope' => implode(' ', self::REQUIRED_SCOPES), 'client_id' => $this->credentials()['keystring'],
'state' => $state, 'code_challenge' => $challenge, 'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);
}
@@ -49,7 +56,7 @@ class Etsy
throw new RuntimeException('Etsy returned an invalid OAuth response.');
}
$shop = $this->get('application/users/'.$userId.'/shops', $tokens['access_token']);
$shop = $this->request('GET', 'application/users/'.$userId.'/shops', $tokens['access_token']);
if (empty($shop['shop_id'])) {
throw new RuntimeException('This Etsy account does not own a shop.');
}
@@ -61,6 +68,7 @@ class Etsy
'shop_name' => $shop['shop_name'] ?? 'Etsy shop',
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
'scopes' => isset($tokens['scope']) ? trim((string) $tokens['scope']) : null,
]);
}
@@ -68,7 +76,7 @@ class Etsy
public function receipts(EtsyConnection $connection, bool $fresh = false): array
{
return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, function () use ($connection): array {
$response = $this->get('application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
$response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [
'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 50,
]);
$results = array_values(array_filter($response['results'] ?? [], fn ($receipt): bool => is_array($receipt) && ! $this->isCancelledOrRefunded($receipt)));
@@ -92,10 +100,296 @@ class Etsy
public function listings(EtsyConnection $connection): array
{
return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->get(
'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
'state' => 'active', 'limit' => 20,
]));
return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->request(
'GET',
'application/shops/'.$connection->shop_id.'/listings',
$this->accessToken($connection),
['state' => 'active', 'limit' => 20],
));
}
/**
* @return array{
* configured: bool,
* connected: bool,
* shop_name: ?string,
* shop_id: mixed,
* configured_shop_name: string,
* token_expires_at: ?string,
* token_expired: ?bool,
* required_scopes: list<string>,
* connected_scopes: ?list<string>,
* missing_scopes: list<string>,
* reconnect_required: bool,
* reconnect_reason: ?string
* }
*/
public function shopStatus(): array
{
$configured = $this->configured();
$connection = EtsyConnection::first();
$connectedScopes = $this->scopeList($connection?->scopes);
$missing = [];
$reconnect = false;
$reason = null;
if (! $configured) {
$reason = 'Etsy application credentials are not configured. Save them on the Settings page or set ETSY_KEYSTRING and ETSY_SHARED_SECRET.';
} elseif (! $connection) {
$reason = 'Etsy shop is not connected. An admin must connect the shop in the dashboard.';
} elseif (strcasecmp($connection->shop_name, $this->credentials()['shop_name']) !== 0) {
$reconnect = true;
$reason = 'The Etsy account does not own the configured HickLab3D shop.';
} elseif ($connectedScopes === null) {
$reconnect = true;
$reason = 'OAuth scopes are unknown on this connection. An admin must reconnect Etsy in the dashboard after deploying expanded shop-management scopes.';
} else {
$missing = array_values(array_diff(self::REQUIRED_SCOPES, $connectedScopes));
$reconnect = $missing !== [];
$reason = $reconnect
? 'Missing OAuth scopes: '.implode(', ', $missing).'. An admin must reconnect Etsy in the dashboard.'
: null;
}
return [
'configured' => $configured,
'connected' => $connection !== null,
'shop_name' => $connection?->shop_name,
'shop_id' => $connection?->shop_id,
'configured_shop_name' => $this->credentials()['shop_name'],
'token_expires_at' => $connection?->expires_at?->toIso8601String(),
'token_expired' => $connection ? $connection->expires_at->isPast() : null,
'required_scopes' => self::REQUIRED_SCOPES,
'connected_scopes' => $connectedScopes,
'missing_scopes' => $missing,
'reconnect_required' => $reconnect,
'reconnect_reason' => $reason,
];
}
public function connectedShop(): EtsyConnection
{
if (! $this->configured()) {
throw new RuntimeException('Etsy application credentials are not configured.');
}
$connection = EtsyConnection::first();
if (! $connection) {
throw new RuntimeException('Etsy shop is not connected. An admin must connect the shop in the dashboard.');
}
if (strcasecmp($connection->shop_name, $this->credentials()['shop_name']) !== 0) {
throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.');
}
return $connection;
}
/**
* @return array{count: int, limit: int, offset: int, state: string, results: list<array<string, mixed>>}
*/
public function listListings(EtsyConnection $connection, string $state = 'active', int $limit = 25, int $offset = 0): array
{
$state = strtolower($state);
if (! in_array($state, self::LISTING_STATES, true)) {
throw new RuntimeException('Listing state must be one of: '.implode(', ', self::LISTING_STATES).'.');
}
[$limit, $offset] = $this->page($limit, $offset);
$response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [
'state' => $state, 'limit' => $limit, 'offset' => $offset,
]);
return [
'count' => (int) ($response['count'] ?? count($response['results'] ?? [])),
'limit' => $limit,
'offset' => $offset,
'state' => $state,
'results' => array_map(fn (array $listing): array => $this->summarizeListing($listing), $this->results($response)),
];
}
/**
* @return array<string, mixed>
*/
public function getListing(EtsyConnection $connection, int $listingId): array
{
$listing = $this->request('GET', 'application/listings/'.$listingId, $this->accessToken($connection));
$this->assertListingBelongsToShop($connection, $listing);
return $this->summarizeListing($listing, detailed: true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function createDraftListing(EtsyConnection $connection, array $payload): array
{
foreach (['quantity', 'title', 'description', 'price', 'who_made', 'when_made', 'taxonomy_id'] as $field) {
if (! array_key_exists($field, $payload) || $payload[$field] === '' || $payload[$field] === null) {
throw new RuntimeException("Draft listing requires {$field}.");
}
}
unset($payload['state'], $payload['shop_id'], $payload['listing_id']);
$listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload);
$this->cache->forget('etsy.listings.'.$connection->shop_id);
return $this->summarizeListing($listing, detailed: true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updateListing(EtsyConnection $connection, int $listingId, array $payload): array
{
if (isset($payload['state']) && strtolower((string) $payload['state']) === 'active') {
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true. HL3D listings stay draft until the operator authorises going live.');
}
unset($payload['shop_id'], $payload['listing_id']);
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload);
$this->assertListingBelongsToShop($connection, $listing);
$this->cache->forget('etsy.listings.'.$connection->shop_id);
return $this->summarizeListing($listing, detailed: true);
}
/**
* @return array<string, mixed>
*/
public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array
{
if (! $confirm) {
throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.');
}
$listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']);
$this->assertListingBelongsToShop($connection, $listing);
$this->cache->forget('etsy.listings.'.$connection->shop_id);
return $this->summarizeListing($listing, detailed: true);
}
/**
* @return array<string, mixed>
*/
public function getListingInventory(EtsyConnection $connection, int $listingId): array
{
return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection));
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array
{
if (! isset($payload['products']) && array_key_exists('quantity', $payload)) {
$payload = ['products' => $this->productsWithQuantity($this->getListingInventory($connection, $listingId), (int) $payload['quantity'])];
}
if (! isset($payload['products']) || ! is_array($payload['products'])) {
throw new RuntimeException('Inventory update requires products or quantity.');
}
$inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: ['products' => $payload['products']], jsonBody: true);
$this->cache->forget('etsy.listings.'.$connection->shop_id);
return $inventory;
}
/**
* @param array{was_paid?: bool|string|null, was_shipped?: bool|string|null, limit?: int, offset?: int} $filters
* @return array{count: int, limit: int, offset: int, results: list<array<string, mixed>>}
*/
public function listReceipts(EtsyConnection $connection, array $filters = []): array
{
[$limit, $offset] = $this->page((int) ($filters['limit'] ?? 25), (int) ($filters['offset'] ?? 0));
$query = ['limit' => $limit, 'offset' => $offset];
foreach (['was_paid', 'was_shipped'] as $flag) {
if (array_key_exists($flag, $filters) && $filters[$flag] !== null && $filters[$flag] !== '') {
$query[$flag] = filter_var($filters[$flag], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
}
}
$response = $this->request('GET', 'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), $query);
return [
'count' => (int) ($response['count'] ?? count($response['results'] ?? [])),
'limit' => $limit,
'offset' => $offset,
'results' => array_map(fn (array $receipt): array => $this->summarizeReceipt($receipt), $this->results($response)),
];
}
/**
* @return array<string, mixed>
*/
public function getReceipt(EtsyConnection $connection, int $receiptId): array
{
$receipt = $this->request('GET', 'application/shops/'.$connection->shop_id.'/receipts/'.$receiptId, $this->accessToken($connection));
return $this->summarizeReceipt($receipt, detailed: true);
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array
{
if ($trackingCode === '' || $carrierName === '') {
throw new RuntimeException('Marking a receipt shipped requires tracking_code and carrier_name.');
}
$body = ['tracking_code' => $trackingCode, 'carrier_name' => $carrierName];
if (array_key_exists('send_bcc', $extra) && $extra['send_bcc'] !== null) {
$body['send_bcc'] = filter_var($extra['send_bcc'], FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false';
}
if (filled($extra['note_to_buyer'] ?? null)) {
$body['note_to_buyer'] = (string) $extra['note_to_buyer'];
}
$shipment = $this->request('POST', 'application/shops/'.$connection->shop_id.'/receipts/'.$receiptId.'/tracking', $this->accessToken($connection), body: $body);
$this->cache->forget('etsy.receipts.'.$connection->shop_id);
return [
'receipt_id' => $shipment['receipt_id'] ?? $receiptId,
'tracking_code' => $shipment['tracking_code'] ?? $trackingCode,
'carrier_name' => $shipment['carrier_name'] ?? $carrierName,
'shop_id' => $connection->shop_id,
];
}
/**
* @param array{limit?: int, offset?: int} $filters
* @return array{count: int, limit: int, offset: int, results: list<array<string, mixed>>, conversations_api: array<string, mixed>}
*/
public function listReceiptMessages(EtsyConnection $connection, array $filters = []): array
{
$page = $this->listReceipts($connection, $filters);
$results = array_values(array_filter($page['results'], fn (array $receipt): bool => ($receipt['has_buyer_message'] ?? false) || ($receipt['has_seller_message'] ?? false)));
return [
'count' => count($results),
'limit' => $page['limit'],
'offset' => $page['offset'],
'results' => $results,
'conversations_api' => $this->conversationsGap(),
];
}
/**
* @return array<string, mixed>
*/
public function getReceiptMessages(EtsyConnection $connection, int $receiptId): array
{
$receipt = $this->getReceipt($connection, $receiptId);
return [
'receipt_id' => $receipt['receipt_id'] ?? $receiptId,
'message_from_buyer' => $receipt['message_from_buyer'] ?? null,
'message_from_seller' => $receipt['message_from_seller'] ?? null,
'message_from_payment' => $receipt['message_from_payment'] ?? null,
'conversations_api' => $this->conversationsGap(),
];
}
public function redirectUri(): string
{
return $this->credentials()['redirect_uri'] ?: route('etsy.callback');
}
private function accessToken(EtsyConnection $connection): string
@@ -114,23 +408,204 @@ class Etsy
$connection->update([
'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'],
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)),
...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [],
]);
return $tokens['access_token'];
}
private function get(string $path, string $accessToken, array $query = []): array
/**
* @param array<string, mixed> $query
* @param array<string, mixed>|null $body
* @return array<string, mixed>
*/
private function request(string $method, string $path, string $accessToken, array $query = [], ?array $body = null, bool $jsonBody = false): array
{
$credentials = $this->credentials();
$pending = Http::withHeaders(['x-api-key' => $credentials['keystring'].':'.$credentials['shared_secret']])
->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(15);
$url = self::API_URL.'/'.$path;
$method = strtoupper($method);
return Http::withHeaders(['x-api-key' => $credentials['keystring'].':'.$credentials['shared_secret']])
->withToken($accessToken)->acceptJson()->connectTimeout(3)->timeout(10)
->get(self::API_URL.'/'.$path, $query)->throw()->json() ?? [];
try {
$response = match ($method) {
'GET', 'DELETE' => $pending->{strtolower($method)}($url, $query),
default => $jsonBody
? $pending->asJson()->{strtolower($method)}($url, $body ?? [])
: $pending->asForm()->{strtolower($method)}($url, $body ?? []),
};
$decoded = $response->throw()->json();
} catch (RequestException $exception) {
throw new RuntimeException($this->httpErrorMessage($exception), (int) $exception->response?->status(), $exception);
}
return is_array($decoded) ? $decoded : [];
}
public function redirectUri(): string
private function httpErrorMessage(RequestException $exception): string
{
return $this->credentials()['redirect_uri'] ?: route('etsy.callback');
$status = $exception->response?->status();
$body = $exception->response?->json();
$etsy = is_array($body) ? (string) ($body['error'] ?? $body['error_description'] ?? '') : '';
$message = 'Etsy API request failed'.($status ? " (HTTP {$status})" : '').($etsy !== '' ? ': '.$etsy : '.');
if ($status === 403) {
$message .= ' This usually means the stored OAuth token is missing write scopes. An admin must reconnect Etsy in the dashboard.';
}
return $message;
}
/**
* @param array<string, mixed> $listing
*/
private function assertListingBelongsToShop(EtsyConnection $connection, array $listing): void
{
if (isset($listing['shop_id']) && (int) $listing['shop_id'] !== (int) $connection->shop_id) {
throw new RuntimeException('That listing does not belong to the connected HickLab3D shop.');
}
}
/**
* @param array<string, mixed> $listing
* @return array<string, mixed>
*/
private function summarizeListing(array $listing, bool $detailed = false): array
{
$summary = [
'listing_id' => $listing['listing_id'] ?? null,
'shop_id' => $listing['shop_id'] ?? null,
'title' => $listing['title'] ?? null,
'state' => $listing['state'] ?? null,
'quantity' => $listing['quantity'] ?? null,
'price' => $listing['price'] ?? null,
'url' => $listing['url'] ?? null,
'taxonomy_id' => $listing['taxonomy_id'] ?? null,
'last_modified_timestamp' => $listing['last_modified_timestamp'] ?? null,
];
if ($detailed) {
$summary['description'] = $listing['description'] ?? null;
$summary['who_made'] = $listing['who_made'] ?? null;
$summary['when_made'] = $listing['when_made'] ?? null;
$summary['tags'] = $listing['tags'] ?? [];
$summary['materials'] = $listing['materials'] ?? [];
$summary['shipping_profile_id'] = $listing['shipping_profile_id'] ?? null;
$summary['is_personalizable'] = $listing['is_personalizable'] ?? null;
}
return $summary;
}
/**
* @param array<string, mixed> $receipt
* @return array<string, mixed>
*/
private function summarizeReceipt(array $receipt, bool $detailed = false): array
{
$summary = [
'receipt_id' => $receipt['receipt_id'] ?? null,
'status' => $receipt['status'] ?? null,
'was_paid' => $receipt['was_paid'] ?? null,
'was_shipped' => $receipt['was_shipped'] ?? null,
'was_canceled' => $receipt['was_canceled'] ?? null,
'create_timestamp' => $receipt['create_timestamp'] ?? null,
'grandtotal' => $receipt['grandtotal'] ?? null,
'name' => $receipt['name'] ?? null,
'city' => $receipt['city'] ?? null,
'state' => $receipt['state'] ?? null,
'zip' => $receipt['zip'] ?? null,
'country_iso' => $receipt['country_iso'] ?? null,
'has_buyer_message' => filled($receipt['message_from_buyer'] ?? null),
'has_seller_message' => filled($receipt['message_from_seller'] ?? null),
];
if ($detailed) {
$summary['first_line'] = $receipt['first_line'] ?? null;
$summary['second_line'] = $receipt['second_line'] ?? null;
$summary['message_from_buyer'] = $receipt['message_from_buyer'] ?? null;
$summary['message_from_seller'] = $receipt['message_from_seller'] ?? null;
$summary['message_from_payment'] = $receipt['message_from_payment'] ?? null;
}
return $summary;
}
/**
* @param array<string, mixed> $inventory
* @return list<array<string, mixed>>
*/
private function productsWithQuantity(array $inventory, int $quantity): array
{
$products = [];
foreach ($inventory['products'] ?? [] as $product) {
if (! is_array($product)) {
continue;
}
$offerings = [];
foreach ($product['offerings'] ?? [] as $offering) {
if (! is_array($offering)) {
continue;
}
$price = $offering['price'] ?? 0;
if (is_array($price)) {
$price = (float) ($price['amount'] ?? 0) / max(1, (int) ($price['divisor'] ?? 1));
}
$offerings[] = [
'price' => $price,
'quantity' => $quantity,
'is_enabled' => $offering['is_enabled'] ?? true,
];
}
$products[] = [
'sku' => $product['sku'] ?? '',
'property_values' => $product['property_values'] ?? [],
'offerings' => $offerings,
];
}
return $products;
}
/**
* @return array{available: bool, reason: string, docs: list<string>}
*/
private function conversationsGap(): array
{
return [
'available' => false,
'reason' => 'Etsy Open API v3 has no shop conversation/inbox endpoints and no conversations_* OAuth scopes. Receipt notes (message_from_buyer / message_from_seller) are the supported v3 surface. Sending a buyer-inbox reply is not available.',
'docs' => [
'https://developer.etsy.com/documentation/essentials/authentication',
'https://developer.etsy.com/documentation/reference',
'https://github.com/etsy/open-api/discussions/1645',
],
];
}
/**
* @return list<array<string, mixed>>
*/
private function results(array $response): array
{
return array_values(array_filter($response['results'] ?? [], 'is_array'));
}
/**
* @return array{0: int, 1: int}
*/
private function page(int $limit, int $offset): array
{
return [max(1, min(100, $limit)), max(0, $offset)];
}
/**
* @return list<string>|null
*/
private function scopeList(?string $scopes): ?array
{
if ($scopes === null || trim($scopes) === '') {
return null;
}
return array_values(array_filter(preg_split('/\s+/', trim($scopes)) ?: []));
}
/** @return array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string} */
+328
View File
@@ -0,0 +1,328 @@
<?php
namespace App\Services;
use JsonException;
use RuntimeException;
use Throwable;
class EtsyMcpServer
{
public function __construct(private Etsy $etsy) {}
public function handleLine(string $line): ?string
{
$line = trim($line);
if ($line === '') {
return null;
}
try {
$message = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return $this->encode(['jsonrpc' => '2.0', 'id' => null, 'error' => ['code' => -32700, 'message' => 'Parse error']]);
}
if (! is_array($message) || ! isset($message['method']) || ! is_string($message['method'])) {
return $this->encode(['jsonrpc' => '2.0', 'id' => $message['id'] ?? null, 'error' => ['code' => -32600, 'message' => 'Invalid Request']]);
}
$id = $message['id'] ?? null;
$isNotification = ! array_key_exists('id', $message);
try {
$result = $this->dispatch($message['method'], is_array($message['params'] ?? null) ? $message['params'] : []);
if ($isNotification) {
return null;
}
return $this->encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]);
} catch (Throwable $exception) {
if ($isNotification) {
return null;
}
return $this->encode([
'jsonrpc' => '2.0',
'id' => $id,
'error' => ['code' => -32603, 'message' => $this->redactString($exception->getMessage())],
]);
}
}
/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
private function dispatch(string $method, array $params): array
{
return match ($method) {
'initialize' => [
'protocolVersion' => is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : '2024-11-05',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'etsy', 'version' => '1.0.0'],
'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts unless etsy_publish_listing is called with confirm=true. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.',
],
'notifications/initialized', 'initialized' => [],
'ping' => ['ok' => true],
'tools/list' => ['tools' => $this->tools()],
'tools/call' => $this->callTool((string) ($params['name'] ?? ''), is_array($params['arguments'] ?? null) ? $params['arguments'] : []),
default => throw new RuntimeException('Method not found: '.$method),
};
}
/**
* @param array<string, mixed> $arguments
* @return array{content: list<array{type: string, text: string}>, isError?: bool}
*/
private function callTool(string $name, array $arguments): array
{
try {
$data = $this->invoke($name, $arguments);
$payload = $this->redact($data);
return [
'content' => [['type' => 'text', 'text' => json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)]],
];
} catch (Throwable $exception) {
$message = $this->redactString($exception->getMessage());
return [
'content' => [['type' => 'text', 'text' => $message]],
'isError' => true,
];
}
}
/**
* @param array<string, mixed> $arguments
*/
private function invoke(string $name, array $arguments): mixed
{
if ($name === 'etsy_shop_status') {
return $this->etsy->shopStatus();
}
$connection = $this->etsy->connectedShop();
return match ($name) {
'etsy_list_listings' => $this->etsy->listListings(
$connection,
(string) ($arguments['state'] ?? 'active'),
(int) ($arguments['limit'] ?? 25),
(int) ($arguments['offset'] ?? 0),
),
'etsy_get_listing' => $this->etsy->getListing($connection, $this->requiredInt($arguments, 'listing_id')),
'etsy_create_draft_listing' => $this->etsy->createDraftListing($connection, $arguments),
'etsy_update_listing' => $this->etsy->updateListing($connection, $this->requiredInt($arguments, 'listing_id'), $arguments),
'etsy_publish_listing' => $this->etsy->publishListing(
$connection,
$this->requiredInt($arguments, 'listing_id'),
filter_var($arguments['confirm'] ?? false, FILTER_VALIDATE_BOOLEAN),
),
'etsy_get_listing_inventory' => $this->etsy->getListingInventory($connection, $this->requiredInt($arguments, 'listing_id')),
'etsy_update_listing_inventory' => $this->etsy->updateListingInventory($connection, $this->requiredInt($arguments, 'listing_id'), $arguments),
'etsy_list_receipts' => $this->etsy->listReceipts($connection, $arguments),
'etsy_get_receipt' => $this->etsy->getReceipt($connection, $this->requiredInt($arguments, 'receipt_id')),
'etsy_mark_receipt_shipped' => $this->etsy->createReceiptShipment(
$connection,
$this->requiredInt($arguments, 'receipt_id'),
(string) ($arguments['tracking_code'] ?? ''),
(string) ($arguments['carrier_name'] ?? ''),
$arguments,
),
'etsy_list_receipt_messages' => $this->etsy->listReceiptMessages($connection, $arguments),
'etsy_get_receipt_messages' => $this->etsy->getReceiptMessages($connection, $this->requiredInt($arguments, 'receipt_id')),
'' => throw new RuntimeException('Tool name is required.'),
default => throw new RuntimeException('Unknown tool: '.$name),
};
}
/**
* @return list<array{name: string, description: string, inputSchema: array<string, mixed>}>
*/
private function tools(): array
{
return [
$this->tool('etsy_shop_status', 'Check whether the HickLab3D Etsy app is configured, which shop is connected, token expiry, and whether an admin must reconnect to grant expanded OAuth scopes. Never returns tokens.', [
'type' => 'object',
'properties' => new \stdClass,
'additionalProperties' => false,
]),
$this->tool('etsy_list_listings', 'List HickLab3D shop listings. Filter by state: draft, active, inactive, expired, or sold_out. Returns listing ids, titles, state, quantity, and price.', [
'type' => 'object',
'properties' => [
'state' => ['type' => 'string', 'enum' => Etsy::LISTING_STATES, 'description' => 'Listing state filter. Default active.'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'description' => 'Page size, default 25, max 100.'],
'offset' => ['type' => 'integer', 'minimum' => 0],
],
'additionalProperties' => false,
]),
$this->tool('etsy_get_listing', 'Get one HickLab3D listing by listing_id, including description and tags.', [
'type' => 'object',
'properties' => [
'listing_id' => ['type' => 'integer', 'description' => 'Etsy listing id.'],
],
'required' => ['listing_id'],
'additionalProperties' => false,
]),
$this->tool('etsy_create_draft_listing', 'Create a HickLab3D listing as an Etsy draft. Never publishes. Required: title, description, price, quantity, who_made, when_made, taxonomy_id. Do not pass state=active.', [
'type' => 'object',
'properties' => [
'title' => ['type' => 'string'],
'description' => ['type' => 'string'],
'price' => ['type' => 'number', 'description' => 'Listing price as a decimal, e.g. 12.5'],
'quantity' => ['type' => 'integer', 'minimum' => 1],
'who_made' => ['type' => 'string', 'enum' => ['i_did', 'someone_else', 'collective']],
'when_made' => ['type' => 'string', 'description' => 'Etsy when_made value, e.g. made_to_order or 2020_2025.'],
'taxonomy_id' => ['type' => 'integer'],
'shipping_profile_id' => ['type' => 'integer'],
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
'materials' => ['type' => 'array', 'items' => ['type' => 'string']],
'type' => ['type' => 'string', 'enum' => ['physical', 'download', 'both']],
],
'required' => ['title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id'],
'additionalProperties' => true,
]),
$this->tool('etsy_update_listing', 'Update HickLab3D listing fields (title, description, tags, state=inactive, etc). Refuses state=active — use etsy_publish_listing with confirm=true to go live.', [
'type' => 'object',
'properties' => [
'listing_id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'description' => ['type' => 'string'],
'taxonomy_id' => ['type' => 'integer'],
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
'state' => ['type' => 'string', 'enum' => ['inactive', 'draft'], 'description' => 'inactive deactivates. active is rejected; use etsy_publish_listing.'],
],
'required' => ['listing_id'],
'additionalProperties' => true,
]),
$this->tool('etsy_publish_listing', 'Publish a draft HickLab3D listing (state=active). Requires confirm=true. The listing must already have images (and a shipping profile for physical items).', [
'type' => 'object',
'properties' => [
'listing_id' => ['type' => 'integer'],
'confirm' => ['type' => 'boolean', 'description' => 'Must be true to publish. Default false.'],
],
'required' => ['listing_id', 'confirm'],
'additionalProperties' => false,
]),
$this->tool('etsy_get_listing_inventory', 'Get product offerings and quantities for a HickLab3D listing.', [
'type' => 'object',
'properties' => [
'listing_id' => ['type' => 'integer'],
],
'required' => ['listing_id'],
'additionalProperties' => false,
]),
$this->tool('etsy_update_listing_inventory', 'Update listing inventory. Pass products (Etsy inventory payload) or quantity to set every offering quantity on the listing.', [
'type' => 'object',
'properties' => [
'listing_id' => ['type' => 'integer'],
'quantity' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Shortcut: set every offering quantity to this value.'],
'products' => ['type' => 'array', 'description' => 'Full Etsy products array for updateListingInventory.'],
],
'required' => ['listing_id'],
'additionalProperties' => false,
]),
$this->tool('etsy_list_receipts', 'List HickLab3D shop receipts/orders. Optional was_paid and was_shipped filters; not limited to the dashboard unshipped subset. Returns ids, status, totals, and location — not email.', [
'type' => 'object',
'properties' => [
'was_paid' => ['type' => 'boolean'],
'was_shipped' => ['type' => 'boolean'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'offset' => ['type' => 'integer', 'minimum' => 0],
],
'additionalProperties' => false,
]),
$this->tool('etsy_get_receipt', 'Get one HickLab3D receipt by receipt_id, including shipping lines and receipt messages. Email is omitted.', [
'type' => 'object',
'properties' => [
'receipt_id' => ['type' => 'integer'],
],
'required' => ['receipt_id'],
'additionalProperties' => false,
]),
$this->tool('etsy_mark_receipt_shipped', 'Mark a HickLab3D receipt shipped by adding carrier tracking (Etsy createReceiptShipment). Requires tracking_code and carrier_name.', [
'type' => 'object',
'properties' => [
'receipt_id' => ['type' => 'integer'],
'tracking_code' => ['type' => 'string'],
'carrier_name' => ['type' => 'string', 'description' => 'Etsy carrier name, e.g. evri, royal_mail, ups.'],
'send_bcc' => ['type' => 'boolean'],
'note_to_buyer' => ['type' => 'string'],
],
'required' => ['receipt_id', 'tracking_code', 'carrier_name'],
'additionalProperties' => false,
]),
$this->tool('etsy_list_receipt_messages', 'List HickLab3D receipts that include buyer or seller receipt notes. Etsy Open API v3 has no conversation inbox; this is receipt messages only.', [
'type' => 'object',
'properties' => [
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'offset' => ['type' => 'integer', 'minimum' => 0],
],
'additionalProperties' => false,
]),
$this->tool('etsy_get_receipt_messages', 'Get message_from_buyer / message_from_seller / message_from_payment for one receipt. Sending a buyer-inbox reply is not available on Etsy Open API v3.', [
'type' => 'object',
'properties' => [
'receipt_id' => ['type' => 'integer'],
],
'required' => ['receipt_id'],
'additionalProperties' => false,
]),
];
}
/**
* @param array<string, mixed> $inputSchema
* @return array{name: string, description: string, inputSchema: array<string, mixed>}
*/
private function tool(string $name, string $description, array $inputSchema): array
{
return compact('name', 'description', 'inputSchema');
}
/**
* @param array<string, mixed> $arguments
*/
private function requiredInt(array $arguments, string $key): int
{
if (! isset($arguments[$key]) || ! is_numeric($arguments[$key])) {
throw new RuntimeException("{$key} is required.");
}
return (int) $arguments[$key];
}
/**
* @param array<string, mixed> $payload
*/
private function encode(array $payload): string
{
return json_encode($this->redact($payload), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
}
private function redact(mixed $value): mixed
{
if (is_array($value)) {
$out = [];
foreach ($value as $key => $item) {
if (is_string($key) && in_array(strtolower($key), ['access_token', 'refresh_token', 'keystring', 'shared_secret', 'client_secret', 'password', 'api_key'], true)) {
$out[$key] = '[redacted]';
continue;
}
$out[$key] = $this->redact($item);
}
return $out;
}
return is_string($value) ? $this->redactString($value) : $value;
}
private function redactString(string $value): string
{
return preg_replace('/(?i)(access_token|refresh_token|keystring|shared_secret|client_secret)\s*[:=]\s*\S+/', '$1=[redacted]', $value) ?? $value;
}
}