[verified] Harden Etsy MCP validation and scheduled token refresh
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s

This commit is contained in:
2026-11-07 21:54:33 +00:00
parent 960189d14b
commit c8c5822166
13 changed files with 455 additions and 89 deletions
+58 -6
View File
@@ -61,7 +61,7 @@ class EtsyMcpServer
'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.',
'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],
@@ -78,6 +78,18 @@ class EtsyMcpServer
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);
@@ -182,7 +194,7 @@ class EtsyMcpServer
'type' => ['type' => 'string', 'enum' => ['physical', 'download', 'both']],
],
'required' => ['title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id'],
'additionalProperties' => true,
'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',
@@ -195,7 +207,7 @@ class EtsyMcpServer
'state' => ['type' => 'string', 'enum' => ['inactive', 'draft'], 'description' => 'inactive deactivates. active is rejected; use etsy_publish_listing.'],
],
'required' => ['listing_id'],
'additionalProperties' => true,
'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',
@@ -287,11 +299,51 @@ class EtsyMcpServer
*/
private function requiredInt(array $arguments, string $key): int
{
if (! isset($arguments[$key]) || ! is_numeric($arguments[$key])) {
throw new RuntimeException("{$key} is required.");
if (! isset($arguments[$key]) || ! is_int($arguments[$key]) || $arguments[$key] < 1) {
throw new RuntimeException("{$key} must be a positive integer.");
}
return (int) $arguments[$key];
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);
}
}
}
/**