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.
329 lines
16 KiB
PHP
329 lines
16 KiB
PHP
<?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;
|
|
}
|
|
}
|