From 9b1364057130c8d0734481e4daebedb9fe04fbaa Mon Sep 17 00:00:00 2001 From: "Odin (Hermes)" Date: Sat, 7 Nov 2026 15:54:05 +0000 Subject: [PATCH] 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. --- .mcp.json | 7 + README.md | 19 + app/Console/Commands/EtsyMcp.php | 34 ++ app/Models/EtsyConnection.php | 2 +- app/Services/Etsy.php | 501 +++++++++++++++++- app/Services/EtsyMcpServer.php | 328 ++++++++++++ config/services.php | 4 +- ...0_add_scopes_to_etsy_connections_table.php | 28 + tests/Feature/EtsyMcpTest.php | 369 +++++++++++++ tests/Feature/EtsySettingsTest.php | 14 + tests/Feature/IntegrationsTest.php | 7 +- 11 files changed, 1294 insertions(+), 19 deletions(-) create mode 100644 app/Console/Commands/EtsyMcp.php create mode 100644 app/Services/EtsyMcpServer.php create mode 100644 database/migrations/2026_11_07_160000_add_scopes_to_etsy_connections_table.php create mode 100644 tests/Feature/EtsyMcpTest.php diff --git a/.mcp.json b/.mcp.json index 40b4d24..6f6c451 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,6 +6,13 @@ "artisan", "boost:mcp" ] + }, + "etsy": { + "command": "php", + "args": [ + "artisan", + "etsy:mcp" + ] } } } diff --git a/README.md b/README.md index 0344bbb..66adf4d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,25 @@ The migrations use the legacy table and column names. If you point the app at th - Enter Etsy, Parcel2Go, BamBuddy, and dryer connection details on the admin Settings page. Saved values are encrypted in the database and take precedence over matching `.env` values. - Existing `P2G_*`, `BAMBUDDY_*`, `DRYER_*`, and `ETSY_*` environment values remain available as fallbacks. Save each integration on the Settings page to import those values into the database. - Connect the Etsy shop with OAuth after saving its app credentials. The browser talks to authenticated Laravel routes for dryer status and controls. +- Runtime Etsy credentials remain Settings-page rows / `ETSY_*` env (optional aliases: `HL3D_ETSY_KEYSTRING`, `HL3D_ETSY_SECRET`). Never put tokens in MCP config. + +## Etsy MCP + +First-party stdio MCP for HL3D agents. It uses the stored HickLab3D shop connection — agents must not start a new OAuth dance. + +```sh +php artisan etsy:mcp +``` + +Register it from this repo's `.mcp.json` as server name `etsy` (`php` + `artisan etsy:mcp`). Example for an HL3D Hermes profile after this server exists: + +```sh +hermes -p hl3d_teamlead mcp add etsy --command php --args artisan etsy:mcp +``` + +Run the command from the dashboard app root so it inherits `.env`. After deploy, an admin must reconnect Etsy in the dashboard so the token includes `listings_r listings_w transactions_r transactions_w shops_r`. New listings are created as drafts; publishing requires `etsy_publish_listing` with `confirm=true`. + +Etsy Open API v3 has no conversation inbox (`conversations_*` scopes do not exist). Receipt notes are exposed; sending a buyer-inbox reply is not. ## Verification diff --git a/app/Console/Commands/EtsyMcp.php b/app/Console/Commands/EtsyMcp.php new file mode 100644 index 0000000..49a269e --- /dev/null +++ b/app/Console/Commands/EtsyMcp.php @@ -0,0 +1,34 @@ +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; + } +} diff --git a/app/Models/EtsyConnection.php b/app/Models/EtsyConnection.php index fc52590..9aa18c3 100644 --- a/app/Models/EtsyConnection.php +++ b/app/Models/EtsyConnection.php @@ -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']; diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index 0cc12e9..bc70b2a 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -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 */ + public const REQUIRED_SCOPES = ['listings_r', 'listings_w', 'transactions_r', 'transactions_w', 'shops_r']; + + /** @var list */ + 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, + * connected_scopes: ?list, + * missing_scopes: list, + * 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>} + */ + 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 + */ + 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 $payload + * @return array + */ + 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 $payload + * @return array + */ + 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 + */ + 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 + */ + public function getListingInventory(EtsyConnection $connection, int $listingId): array + { + return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection)); + } + + /** + * @param array $payload + * @return array + */ + 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>} + */ + 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 + */ + 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 $extra + * @return array + */ + 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>, conversations_api: array} + */ + 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 + */ + 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 $query + * @param array|null $body + * @return array + */ + 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 $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 $listing + * @return array + */ + 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 $receipt + * @return array + */ + 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 $inventory + * @return list> + */ + 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} + */ + 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> + */ + 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|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} */ diff --git a/app/Services/EtsyMcpServer.php b/app/Services/EtsyMcpServer.php new file mode 100644 index 0000000..2100115 --- /dev/null +++ b/app/Services/EtsyMcpServer.php @@ -0,0 +1,328 @@ +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 $params + * @return array + */ + 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 $arguments + * @return array{content: list, 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 $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}> + */ + 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 $inputSchema + * @return array{name: string, description: string, inputSchema: array} + */ + private function tool(string $name, string $description, array $inputSchema): array + { + return compact('name', 'description', 'inputSchema'); + } + + /** + * @param array $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 $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; + } +} diff --git a/config/services.php b/config/services.php index f49b9e4..afea12d 100644 --- a/config/services.php +++ b/config/services.php @@ -43,8 +43,8 @@ return [ 'scope' => env('P2G_API_SCOPE', 'public-api'), ], 'etsy' => [ - 'keystring' => env('ETSY_KEYSTRING'), - 'shared_secret' => env('ETSY_SHARED_SECRET'), + 'keystring' => env('ETSY_KEYSTRING') ?: env('HL3D_ETSY_KEYSTRING'), + 'shared_secret' => env('ETSY_SHARED_SECRET') ?: env('HL3D_ETSY_SECRET'), 'redirect_uri' => env('ETSY_REDIRECT_URI'), 'shop_name' => env('ETSY_SHOP_NAME', 'HickLab3D'), ], diff --git a/database/migrations/2026_11_07_160000_add_scopes_to_etsy_connections_table.php b/database/migrations/2026_11_07_160000_add_scopes_to_etsy_connections_table.php new file mode 100644 index 0000000..bbe51a0 --- /dev/null +++ b/database/migrations/2026_11_07_160000_add_scopes_to_etsy_connections_table.php @@ -0,0 +1,28 @@ +string('scopes')->nullable()->after('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('etsy_connections', function (Blueprint $table) { + $table->dropColumn('scopes'); + }); + } +}; diff --git a/tests/Feature/EtsyMcpTest.php b/tests/Feature/EtsyMcpTest.php new file mode 100644 index 0000000..8829985 --- /dev/null +++ b/tests/Feature/EtsyMcpTest.php @@ -0,0 +1,369 @@ +mcp(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + + $this->assertSame('2.0', $response['jsonrpc']); + $this->assertSame(1, $response['id']); + $names = array_column($response['result']['tools'], 'name'); + $this->assertSame([ + 'etsy_shop_status', + 'etsy_list_listings', + 'etsy_get_listing', + 'etsy_create_draft_listing', + 'etsy_update_listing', + 'etsy_publish_listing', + 'etsy_get_listing_inventory', + 'etsy_update_listing_inventory', + 'etsy_list_receipts', + 'etsy_get_receipt', + 'etsy_mark_receipt_shipped', + 'etsy_list_receipt_messages', + 'etsy_get_receipt_messages', + ], $names); + $this->assertNotContains('etsy_delete_listing', $names); + $this->assertNotContains('etsy_send_message', $names); + } + + public function test_initialize_and_initialized_notification(): void + { + $response = $this->mcp([ + 'jsonrpc' => '2.0', + 'id' => 'init-1', + 'method' => 'initialize', + 'params' => ['protocolVersion' => '2024-11-05', 'capabilities' => new \stdClass, 'clientInfo' => ['name' => 'test']], + ]); + + $this->assertSame('2024-11-05', $response['result']['protocolVersion']); + $this->assertSame('etsy', $response['result']['serverInfo']['name']); + $this->assertArrayHasKey('tools', $response['result']['capabilities']); + + $this->assertNull($this->mcpRaw(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'])); + } + + public function test_shop_status_reports_disconnected_shop_without_tokens(): void + { + config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']); + + $result = $this->mcpCall('etsy_shop_status'); + + $this->assertFalse($result['isError']); + $this->assertTrue($result['data']['configured']); + $this->assertFalse($result['data']['connected']); + $this->assertSame('HickLab3D', $result['data']['configured_shop_name']); + $this->assertContains('listings_w', $result['data']['required_scopes']); + $this->assertStringNotContainsString('key', json_encode($result['rpc'])); + $this->assertStringNotContainsString('secret', json_encode($result['rpc'])); + $this->assertArrayNotHasKey('access_token', $result['data']); + $this->assertArrayNotHasKey('refresh_token', $result['data']); + } + + public function test_shop_status_asks_admin_to_reconnect_when_scopes_are_missing(): void + { + $this->connectShop('transactions_r listings_r'); + + $result = $this->mcpCall('etsy_shop_status'); + + $this->assertTrue($result['data']['connected']); + $this->assertTrue($result['data']['reconnect_required']); + $this->assertSame(42, $result['data']['shop_id']); + $this->assertSame('HickLab3D', $result['data']['shop_name']); + $this->assertContains('listings_w', $result['data']['missing_scopes']); + $this->assertStringContainsString('reconnect', strtolower($result['data']['reconnect_reason'])); + $this->assertArrayNotHasKey('access_token', $result['data']); + } + + public function test_create_draft_listing_posts_a_draft_and_never_publishes(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.etsy.com/v3/application/shops/42/listings' => Http::response([ + 'listing_id' => 99, 'title' => 'Widget', 'state' => 'draft', 'quantity' => 3, 'shop_id' => 42, + ], 201), + ]); + $this->connectShop(); + + $result = $this->mcpCall('etsy_create_draft_listing', [ + 'title' => 'Widget', + 'description' => 'A printed widget', + 'price' => 12.5, + 'quantity' => 3, + 'who_made' => 'i_did', + 'when_made' => 'made_to_order', + 'taxonomy_id' => 1234, + 'state' => 'active', + ]); + + $this->assertFalse($result['isError']); + $this->assertSame(99, $result['data']['listing_id']); + $this->assertSame('draft', $result['data']['state']); + Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/listings' + && $request->method() === 'POST' + && $request['title'] === 'Widget' + && (int) $request['quantity'] === 3 + && ! array_key_exists('state', $request->data())); + Http::assertSentCount(1); + } + + public function test_publish_listing_requires_confirm_and_patches_active_when_given(): void + { + Http::preventStrayRequests(); + $this->connectShop(); + + $denied = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99]); + $this->assertTrue($denied['isError']); + Http::assertNothingSent(); + + Http::fake([ + 'https://api.etsy.com/v3/application/shops/42/listings/99' => Http::response([ + 'listing_id' => 99, 'state' => 'active', 'shop_id' => 42, 'title' => 'Widget', + ]), + ]); + + $published = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99, 'confirm' => true]); + $this->assertFalse($published['isError']); + $this->assertSame('active', $published['data']['state']); + Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/listings/99' + && $request->method() === 'PATCH' + && $request['state'] === 'active'); + } + + public function test_update_listing_rejects_publishing_without_the_publish_tool(): void + { + Http::preventStrayRequests(); + $this->connectShop(); + + $result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'state' => 'active', 'title' => 'Nope']); + + $this->assertTrue($result['isError']); + $this->assertStringContainsString('etsy_publish_listing', is_string($result['data']) ? $result['data'] : json_encode($result['data'])); + Http::assertNothingSent(); + } + + public function test_list_listings_and_receipts_use_filters_not_dashboard_defaults(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response([ + 'count' => 1, + 'results' => [['listing_id' => 7, 'title' => 'Draft hat', 'state' => 'draft', 'quantity' => 1, 'shop_id' => 42]], + ]), + 'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response([ + 'count' => 1, + 'results' => [['receipt_id' => 15, 'status' => 'Paid', 'was_paid' => true, 'was_shipped' => true, 'name' => 'Buyer', 'grandtotal' => ['amount' => 500, 'divisor' => 100, 'currency_code' => 'GBP']]], + ]), + ]); + $this->connectShop(); + + $listings = $this->mcpCall('etsy_list_listings', ['state' => 'draft', 'limit' => 10, 'offset' => 0]); + $receipts = $this->mcpCall('etsy_list_receipts', ['was_paid' => true, 'was_shipped' => true, 'limit' => 25]); + + $this->assertSame(7, $listings['data']['results'][0]['listing_id']); + $this->assertSame(15, $receipts['data']['results'][0]['receipt_id']); + Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && $request['state'] === 'draft' && (int) $request['limit'] === 10); + Http::assertSent(fn ($request) => str_contains($request->url(), '/receipts') && $request['was_paid'] === 'true' && $request['was_shipped'] === 'true'); + } + + public function test_mark_receipt_shipped_posts_tracking(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.etsy.com/v3/application/shops/42/receipts/15/tracking' => Http::response([ + 'receipt_id' => 15, 'tracking_code' => 'ABC123', 'carrier_name' => 'evri', 'shop_id' => 42, + ]), + ]); + $this->connectShop(); + + $result = $this->mcpCall('etsy_mark_receipt_shipped', [ + 'receipt_id' => 15, + 'tracking_code' => 'ABC123', + 'carrier_name' => 'evri', + ]); + + $this->assertFalse($result['isError']); + Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/receipts/15/tracking' + && $request->method() === 'POST' + && $request['tracking_code'] === 'ABC123' + && $request['carrier_name'] === 'evri'); + } + + public function test_inventory_update_and_receipt_messages_go_through_the_service(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.etsy.com/v3/application/listings/99/inventory' => function ($request) { + return $request->method() === 'PUT' + ? Http::response(['products' => [['sku' => 'HAT', 'offerings' => [['quantity' => 4, 'price' => 12.5, 'is_enabled' => true]]]]]) + : Http::response(['products' => [['product_id' => 1, 'sku' => 'HAT', 'property_values' => [], 'offerings' => [['offering_id' => 2, 'quantity' => 1, 'is_enabled' => true, 'price' => ['amount' => 1250, 'divisor' => 100, 'currency_code' => 'GBP']]]]]]); + }, + 'https://api.etsy.com/v3/application/shops/42/receipts/15' => Http::response([ + 'receipt_id' => 15, + 'message_from_buyer' => 'Please leave in porch', + 'message_from_seller' => null, + 'name' => 'Buyer', + 'email' => 'buyer@example.test', + ]), + 'https://api.etsy.com/v3/application/shops/42/receipts?*' => Http::response([ + 'count' => 1, + 'results' => [[ + 'receipt_id' => 15, + 'message_from_buyer' => 'Please leave in porch', + 'message_from_seller' => '', + 'name' => 'Buyer', + ]], + ]), + ]); + $this->connectShop(); + + $inventory = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 4]); + $this->assertFalse($inventory['isError']); + Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/listings/99/inventory' && $request->method() === 'PUT'); + + $messages = $this->mcpCall('etsy_get_receipt_messages', ['receipt_id' => 15]); + $this->assertFalse($messages['isError']); + $this->assertSame('Please leave in porch', $messages['data']['message_from_buyer']); + $this->assertArrayNotHasKey('email', $messages['data']); + $this->assertFalse($messages['data']['conversations_api']['available']); + + $listed = $this->mcpCall('etsy_list_receipt_messages'); + $this->assertSame(15, $listed['data']['results'][0]['receipt_id']); + } + + public function test_mutations_never_echo_tokens_even_when_etsy_error_bodies_contain_them(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.etsy.com/v3/application/shops/42/listings' => Http::response([ + 'error' => 'invalid token access_token=super-secret-token refresh_token=super-secret-refresh', + ], 403), + ]); + $this->connectShop(); + + $result = $this->mcpCall('etsy_create_draft_listing', [ + 'title' => 'Widget', + 'description' => 'A printed widget', + 'price' => 12.5, + 'quantity' => 3, + 'who_made' => 'i_did', + 'when_made' => 'made_to_order', + 'taxonomy_id' => 1234, + ]); + + $this->assertTrue($result['isError']); + $encoded = json_encode($result['rpc']); + $this->assertStringNotContainsString('super-secret-token', $encoded); + $this->assertStringNotContainsString('123.token', $encoded); + $this->assertStringContainsString('reconnect', strtolower($encoded)); + } + + public function test_wrong_shop_name_is_rejected_without_calling_etsy(): void + { + Http::preventStrayRequests(); + config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']); + EtsyConnection::create([ + 'shop_id' => 99, 'shop_name' => 'SomeoneElse', + 'access_token' => '123.token', 'refresh_token' => '123.refresh', + 'expires_at' => now()->addHour(), + 'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r', + ]); + + $result = $this->mcpCall('etsy_list_listings', ['state' => 'active']); + + $this->assertTrue($result['isError']); + $this->assertStringContainsString('HickLab3D', is_string($result['data']) ? $result['data'] : json_encode($result['data'])); + Http::assertNothingSent(); + } + + /** + * @param array $message + * @return array + */ + private function mcp(array $message): array + { + $raw = $this->mcpRaw($message); + $this->assertNotNull($raw); + $decoded = json_decode($raw, true); + $this->assertIsArray($decoded); + + return $decoded; + } + + /** + * @param array $message + */ + private function mcpRaw(array $message): ?string + { + $command = $this->app->make(EtsyMcp::class); + $command->setLaravel($this->app); + + $stdin = fopen('php://temp', 'w+'); + fwrite($stdin, json_encode($message)."\n"); + rewind($stdin); + + $input = new ArrayInput([]); + $input->bind($command->getDefinition()); + $input->setStream($stdin); + $output = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, false); + + $this->assertSame(0, $command->run($input, $output)); + $raw = trim($output->fetch()); + + return $raw === '' ? null : $raw; + } + + /** + * @param array $arguments + * @return array{rpc: array, data: mixed, isError: bool} + */ + private function mcpCall(string $name, array $arguments = [], int $id = 2): array + { + $response = $this->mcp([ + 'jsonrpc' => '2.0', + 'id' => $id, + 'method' => 'tools/call', + 'params' => ['name' => $name, 'arguments' => $arguments], + ]); + $this->assertArrayHasKey('result', $response); + $text = $response['result']['content'][0]['text'] ?? ''; + $decoded = json_decode($text, true); + + return [ + 'rpc' => $response, + 'data' => is_array($decoded) ? $decoded : $text, + 'isError' => (bool) ($response['result']['isError'] ?? false), + ]; + } + + private function connectShop(string $scopes = 'listings_r listings_w transactions_r transactions_w shops_r'): void + { + config()->set('services.etsy', [ + 'keystring' => 'key', + 'shared_secret' => 'secret', + 'shop_name' => 'HickLab3D', + 'redirect_uri' => 'https://example.test/etsy/callback', + ]); + EtsyConnection::create([ + 'shop_id' => 42, + 'shop_name' => 'HickLab3D', + 'access_token' => '123.token', + 'refresh_token' => '123.refresh', + 'expires_at' => now()->addHour(), + 'scopes' => $scopes, + ]); + } +} diff --git a/tests/Feature/EtsySettingsTest.php b/tests/Feature/EtsySettingsTest.php index 1210980..1cffb0b 100644 --- a/tests/Feature/EtsySettingsTest.php +++ b/tests/Feature/EtsySettingsTest.php @@ -133,4 +133,18 @@ class EtsySettingsTest extends TestCase $this->actingAs($admin)->get(route('settings.index')) ->assertOk()->assertDontSee('server-secret'); } + + public function test_oauth_connect_requests_shop_management_scopes(): void + { + config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'redirect_uri' => 'https://example.test/etsy/callback']); + $admin = User::factory()->create(['role' => 'admin']); + + $location = $this->actingAs($admin)->post(route('etsy.connect'))->headers->get('Location'); + foreach (['listings_r', 'listings_w', 'transactions_r', 'transactions_w', 'shops_r'] as $scope) { + $this->assertStringContainsString($scope, $location); + } + $this->assertStringNotContainsString('listings_d', $location); + $this->assertStringNotContainsString('shops_w', $location); + $this->assertStringNotContainsString('conversations_', $location); + } } diff --git a/tests/Feature/IntegrationsTest.php b/tests/Feature/IntegrationsTest.php index 14710de..b8ce140 100644 --- a/tests/Feature/IntegrationsTest.php +++ b/tests/Feature/IntegrationsTest.php @@ -33,7 +33,7 @@ class IntegrationsTest extends TestCase { Http::preventStrayRequests(); Http::fake([ - 'https://api.etsy.com/v3/public/oauth/token' => Http::response(['access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_in' => 3600]), + 'https://api.etsy.com/v3/public/oauth/token' => Http::response(['access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_in' => 3600, 'scope' => 'listings_r listings_w transactions_r transactions_w shops_r']), 'https://api.etsy.com/v3/application/users/123/shops' => Http::response(['shop_id' => 42, 'shop_name' => 'HickLab3D']), 'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response(['results' => [['receipt_id' => 77, 'name' => 'Buyer']]]), 'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response(['results' => [['title' => '3D print', 'quantity' => 5]]]), @@ -47,6 +47,7 @@ class IntegrationsTest extends TestCase $connection = EtsyConnection::firstOrFail(); $this->assertSame('123.token', $connection->access_token); $this->assertNotSame('123.token', $connection->getRawOriginal('access_token')); + $this->assertSame('listings_r listings_w transactions_r transactions_w shops_r', $connection->scopes); $this->actingAs($admin)->get(route('etsy.index'))->assertOk()->assertSee('3D print')->assertSee('#77'); Http::assertSentCount(4); } @@ -242,11 +243,11 @@ class IntegrationsTest extends TestCase 'shipping_receipt_id' => 77, 'shipping_selected_service' => 'evri-collection', 'shipping_quotes' => [['Service' => ['Slug' => 'evri-collection', 'CollectionType' => 'Collection']]], - 'shipping_collection_dates' => [['CollectionDate' => '2026-10-15T10:00:00Z']], + 'shipping_collection_dates' => [['CollectionDate' => $collectionDate = now()->addDay()->utc()->format('Y-m-d\TH:i:s\Z')]], 'shipping_sender_location' => ['sender_street' => 'Main Street', 'sender_town' => 'London'], ]; $booking = [ - 'collection_date' => '2026-10-15T10:00:00Z', + 'collection_date' => $collectionDate, 'sender_name' => 'Lee Hickman', 'sender_email' => 'lee@example.test', 'sender_phone' => '07123456789', 'sender_property' => '1', 'sender_street' => 'Main Street', 'sender_town' => 'London', 'recipient_name' => 'Buyer Name', 'recipient_email' => 'buyer@example.test', 'recipient_phone' => '07123456788',