Files
HickLab3D_Dashboard/app/Services/EtsyMcpServer.php
T
hermes c8c5822166
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
[verified] Harden Etsy MCP validation and scheduled token refresh
2026-11-07 21:54:33 +00:00

381 lines
19 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. Never publish, activate, renew, reprice a live listing, change inventory or shop settings, or ship orders without explicit operator authorisation for that specific action in the current conversation. confirm=true is only a technical guard, not authorisation. 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 {
$validationArguments = $arguments;
if ($name === 'etsy_create_draft_listing') {
unset($validationArguments['state']);
}
if ($name === 'etsy_update_listing' && ($arguments['state'] ?? null) === 'active') {
throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true.');
}
$tool = collect($this->tools())->firstWhere('name', $name);
if ($tool === null) {
throw new RuntimeException('Unknown tool: '.$name);
}
$this->validateSchema($validationArguments, $tool['inputSchema'], 'arguments');
$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' => false,
]),
$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' => false,
]),
$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_int($arguments[$key]) || $arguments[$key] < 1) {
throw new RuntimeException("{$key} must be a positive integer.");
}
return $arguments[$key];
}
/** @param array<string, mixed> $schema */
private function validateSchema(mixed $value, array $schema, string $path): void
{
$validType = match ($schema['type'] ?? null) {
'object' => is_array($value) && ($value === [] || ! array_is_list($value)),
'array' => is_array($value) && array_is_list($value),
'integer' => is_int($value),
'number' => is_int($value) || is_float($value),
'boolean' => is_bool($value),
'string' => is_string($value),
default => false,
};
if (! $validType
|| (isset($schema['enum']) && ! in_array($value, $schema['enum'], true))
|| (isset($schema['minimum']) && $value < $schema['minimum'])
|| (isset($schema['maximum']) && $value > $schema['maximum'])) {
throw new RuntimeException('Invalid listing payload: '.$path.' does not match the tool schema.');
}
if (($schema['type'] ?? null) === 'object') {
$properties = (array) ($schema['properties'] ?? []);
foreach ($schema['required'] ?? [] as $key) {
if (! array_key_exists($key, $value)) {
throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is required.');
}
}
foreach ($value as $key => $item) {
if (isset($properties[$key])) {
$this->validateSchema($item, $properties[$key], $path.'.'.$key);
} elseif (($schema['additionalProperties'] ?? true) === false) {
throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is not supported.');
}
}
}
if (($schema['type'] ?? null) === 'array' && isset($schema['items'])) {
foreach ($value as $key => $item) {
$this->validateSchema($item, $schema['items'], $path.'.'.$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;
}
}