From d7db31717d5c887a40c66f49139194ff6c7fe087 Mon Sep 17 00:00:00 2001 From: SamOwens1 Date: Fri, 27 Feb 2026 14:57:14 +0000 Subject: [PATCH] Add multi-LLM provider support and latest server changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a provider abstraction layer so the /chat HTTP endpoint can use Anthropic (default), OpenAI, or Google Gemini as the LLM backend. Provider is selected via LLM_PROVIDER env var or per-request. New files: - providers/ package (base class, Anthropic, OpenAI, Gemini implementations) - providers/tool_converter.py for Anthropic → OpenAI/Gemini schema conversion - anthropic_tools.json tool definitions - docs_search.py documentation search engine - mcp_server_instructions.md AI behaviour guidelines - patchworks-knowledge-base.md bundled product documentation Modified files: - server.py — /chat endpoint delegates to configured provider - patchworks_client.py — latest Patchworks API client changes - pyproject.toml — added openai and google-genai dependencies - .env.example — added LLM provider configuration variables No changes to MCP tools, stdio transport, or existing Anthropic behaviour. --- .env.example | 21 + anthropic_tools.json | 603 +++++++++++++ docs_search.py | 142 +++ mcp_server_instructions.md | 184 ++++ patchworks-knowledge-base.md | 784 +++++++++++++++++ patchworks_client.py | 590 ++++++++++--- providers/__init__.py | 46 + providers/anthropic_provider.py | 122 +++ providers/base.py | 39 + providers/gemini_provider.py | 172 ++++ providers/openai_provider.py | 140 +++ providers/tool_converter.py | 92 ++ pyproject.toml | 5 +- server.py | 1445 +++++++++++-------------------- 14 files changed, 3329 insertions(+), 1056 deletions(-) create mode 100644 anthropic_tools.json create mode 100644 docs_search.py create mode 100644 mcp_server_instructions.md create mode 100644 patchworks-knowledge-base.md create mode 100644 providers/__init__.py create mode 100644 providers/anthropic_provider.py create mode 100644 providers/base.py create mode 100644 providers/gemini_provider.py create mode 100644 providers/openai_provider.py create mode 100644 providers/tool_converter.py diff --git a/.env.example b/.env.example index 29ef7f1..dbed113 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,24 @@ PATCHWORKS_TOKEN= XXXXXXXXXXXXXX # Default timeout for HTTP requests PATCHWORKS_TIMEOUT_SECONDS=20 +# Dashboard URL for deep links in failure investigation results +PATCHWORKS_DASHBOARD_URL=https://app.wearepatchworks.com + +# --- LLM Provider Configuration --- +# Which LLM provider to use for the /chat endpoint. +# Options: anthropic (default), openai, gemini +# Can also be overridden per-request via the "provider" field in the /chat JSON body. +LLM_PROVIDER=anthropic + +# Anthropic (Claude) — required if LLM_PROVIDER=anthropic +ANTHROPIC_API_KEY=sk-ant-XXXXXXXXXXXXXX +# ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# OpenAI (ChatGPT) — required if LLM_PROVIDER=openai +# OPENAI_API_KEY=sk-XXXXXXXXXXXXXX +# OPENAI_MODEL=gpt-4o + +# Google Gemini — required if LLM_PROVIDER=gemini +# GOOGLE_API_KEY=XXXXXXXXXXXXXX +# GEMINI_MODEL=gemini-2.5-flash + diff --git a/anthropic_tools.json b/anthropic_tools.json new file mode 100644 index 0000000..e245acc --- /dev/null +++ b/anthropic_tools.json @@ -0,0 +1,603 @@ +[ + { + "name": "get_all_flows", + "description": "List flows from the Core API.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "per_page": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + }, + "include": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Comma-separated includes (optional)" + } + } + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "get_flow_runs", + "description": "Query flow runs (filter by status, started_after; sort; includes).", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "status": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "1=STARTED, 2=SUCCESS, 3=FAILURE, 4=STOPPED, 5=PARTIAL_SUCCESS" + }, + "started_after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Timestamp or epoch-ms as string" + }, + "flow_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Filter runs by flow ID" + }, + "page": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "per_page": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + }, + "sort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "-started_at" + }, + "include": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null + } + } + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "get_flow_run_logs", + "description": "Retrieve logs for a specific flow run (optionally with payload IDs).", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "run_id": { + "type": "string" + }, + "per_page": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 200 + }, + "page": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "sort": { + "type": "string", + "default": "id" + }, + "include": { + "type": "string", + "default": "flowRunLogMetadata" + }, + "fields_flowStep": { + "type": "string", + "default": "id,name" + }, + "load_payload_ids": { + "type": "boolean", + "default": true + } + }, + "required": [ + "run_id" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "summarise_failed_run", + "description": "Summarise what went wrong in a failed run by inspecting log levels/messages.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "run_id": { + "type": "string" + }, + "max_logs": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "run_id" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "triage_latest_failures", + "description": "Fetch recent failed runs and return a compact summary for each.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "started_after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Timestamp/epoch-ms (string) to filter newer runs" + }, + "limit": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 200, + "description": "How many failed runs to summarise" + }, + "per_run_log_limit": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 500, + "description": "Log entries per run to fetch" + } + } + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "download_payload", + "description": "Download payload bytes for a given payload metadata ID (returned as base64).", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "payload_metadata_id": { + "type": "string" + } + }, + "required": [ + "payload_metadata_id" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "start_flow", + "description": "Trigger a flow run via the Start service (/flows/{id}/start).", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "flow_id": { + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional JSON payload" + } + }, + "required": [ + "flow_id" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "list_data_pools", + "description": "List all data/dedupe pools.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "per_page": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + } + } + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "get_deduped_data", + "description": "Retrieve deduplicated data for a specific pool.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "pool_id": { + "type": "string" + }, + "page": { + "type": "integer", + "default": 1, + "minimum": 1 + }, + "per_page": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + } + }, + "required": [ + "pool_id" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "create_process_flow_from_prompt", + "description": "Build a generic flow from a natural-language prompt and import it. Produces a Try/Catch \u2192 Source Connector \u2192 Batch \u2192 Map \u2192 Destination Connector skeleton. NOTE: For reliable flow creation with full control over structure, consider using create_process_flow_from_json instead.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "e.g. 'create a process flow for Shopify to NetSuite orders'" + }, + "priority": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 5, + "description": "Flow priority (1 highest)" + }, + "schedule_cron": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "0 * * * *", + "description": "Cron schedule; set None to omit" + }, + "enable": { + "type": "boolean", + "default": false, + "description": "Whether to enable on import" + } + }, + "required": [ + "prompt" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "create_process_flow_from_json", + "description": "Import a flow with the exact JSON body provided. Accepts the same structure as flow exports. REQUIRED STRUCTURE: The body must contain these top-level keys: 'metadata' (with company_name, flow_name, exported_at, exported_by, and import_summary), 'flow' (with name, description, is_enabled, and versions array containing steps and connections), 'systems' (array, can be empty), 'scripts' (array, can be empty), and 'dependencies' (array, can be empty). Each flow version must include at least one step (typically a trigger). See the flow creation documentation in the knowledge base for the complete structure.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "body": { + "type": "object", + "additionalProperties": true, + "description": "Complete /flows/import JSON structure. Must include: metadata (with import_summary), flow (with versions containing steps array), systems (array), scripts (array), and dependencies (array). See knowledge base for minimal template example." + } + }, + "required": [ + "body" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "investigate_failure", + "description": "All-in-one failure investigation. Provide a flow name, flow ID, or specific run ID and this tool will: (1) resolve the flow, (2) find the most recent failed run, (3) summarise logs and errors, (4) download and decode catch-route payloads, and (5) if the payload references an originating flow run (alert/catch pattern), follow the chain, resolve its flow_id/flow_name, and summarise that originating run too. Returns originating_flow_id and originating_flow_name when an alert chain is detected — use originating_flow_id (not the alert flow's ID) when retrying. Use this as your FIRST tool when investigating any failure.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "flow_id": { + "anyOf": [ + { "type": "integer" }, + { "type": "null" } + ], + "default": null, + "description": "Numeric flow ID (if known)" + }, + "flow_name": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ], + "default": null, + "description": "Flow name to search for (case-insensitive)" + }, + "run_id": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ], + "default": null, + "description": "Specific flow run ID to investigate (if known)" + }, + "include_payload": { + "type": "boolean", + "default": false, + "description": "Include full payload content in the response. Alert chain following (resolving originating_flow_id) always happens regardless of this flag. Only enable when you need the raw payload data." + }, + "failed_at": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ], + "default": null, + "description": "Timestamp of the failure from the alert message (e.g. '2026-02-24 13:34:04'). When provided, matches the run closest to this timestamp instead of blindly picking the most recent run. ALWAYS extract and pass this from the Slack alert's 'Failed At' field." + } + } + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "get_run_payloads", + "description": "Retrieve all payloads for a flow run in a single call. Downloads and decodes each payload found in the run's logs. Optionally filter by step name (e.g. 'Catch' for the catch-route payload, 'Source Connector' for the inbound data). Use this when the user asks 'what payload did we send?' or 'show me the payload'.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "type": "object", + "properties": { + "run_id": { + "type": "string", + "description": "Flow run ID to fetch payloads for" + }, + "step_name": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ], + "default": null, + "description": "Filter payloads by step name (e.g. 'Catch', 'Source Connector'). Case-insensitive partial match." + } + }, + "required": [ + "run_id" + ] + } + }, + "required": [ + "args" + ] + } + }, + { + "name": "get_inventory", + "description": "Input schema for querying inventory. Returns inventory data for specific SKUs.", + "input_schema": { + "type": "object", + "properties": { + "args": { + "anyOf": [ + { + "type": "object", + "properties": { + "skus": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "default": null + }, + "locationIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "default": null + } + } + }, + { + "type": "null" + } + ], + "default": null + } + } + } + }, + { + "name": "search_docs", + "description": "Search the Patchworks product documentation knowledge base. Use this to answer questions about Patchworks concepts, features, and configuration that are not specific to a particular account's data. Covers: getting started, registration, subscription tiers, company setup, users & roles, marketplace, blueprints, connectors & instances, process flows, virtual environments, general settings, connector builder, custom scripting, the Patchworks API, the Patchworks MCP server, and Stockr.", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural-language question or keywords to search the Patchworks product documentation for" + }, + "max_results": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Maximum number of documentation sections to return" + } + }, + "required": [ + "query" + ] + } + } +] \ No newline at end of file diff --git a/docs_search.py b/docs_search.py new file mode 100644 index 0000000..7062f1a --- /dev/null +++ b/docs_search.py @@ -0,0 +1,142 @@ +""" +Lightweight documentation search engine for the Patchworks knowledge base. +Chunks the bundled markdown by section, builds an inverted index, and +supports keyword + fuzzy matching queries. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Dict, Any + + +# --------------------------------------------------------------------------- +# Chunk the knowledge base into sections +# --------------------------------------------------------------------------- + +_SECTION_RE = re.compile(r"^## \d+\.\s+", re.MULTILINE) + +def _load_sections(path: Path) -> List[Dict[str, str]]: + """Split the knowledge-base markdown into titled sections.""" + text = path.read_text(encoding="utf-8") + parts = _SECTION_RE.split(text) + titles = _SECTION_RE.findall(text) + + sections: List[Dict[str, str]] = [] + for i, title_prefix in enumerate(titles): + # The raw split gives us the content *after* each heading marker. + # Re-join with heading so we can extract the title line. + block = parts[i + 1] if (i + 1) < len(parts) else "" + first_line, _, body = block.partition("\n") + title = first_line.strip() + # Pull out the **Source:** URL if present + source_match = re.search(r"\*\*Source:\*\*\s*(https?://\S+)", body) + source_url = source_match.group(1) if source_match else "" + sections.append({ + "title": title, + "source_url": source_url, + "content": body.strip(), + }) + return sections + + +# --------------------------------------------------------------------------- +# Simple inverted index +# --------------------------------------------------------------------------- + +_WORD_RE = re.compile(r"[a-z0-9]+") + +_STOPWORDS = frozenset({ + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", + "have", "has", "had", "do", "does", "did", "will", "would", "could", + "should", "may", "might", "shall", "can", "need", "must", + "i", "me", "my", "we", "our", "you", "your", "he", "she", "it", + "they", "them", "their", "its", "this", "that", "these", "those", + "what", "which", "who", "whom", "how", "when", "where", "why", + "in", "on", "at", "to", "for", "of", "with", "by", "from", "as", + "into", "about", "between", "through", "during", "before", "after", + "and", "but", "or", "nor", "not", "so", "if", "then", "than", + "all", "each", "every", "both", "few", "more", "most", "some", "any", + "no", "only", "same", "such", "too", "very", "just", +}) + +def _tokenize(text: str) -> List[str]: + return _WORD_RE.findall(text.lower()) + +def _tokenize_query(text: str) -> List[str]: + """Tokenize a search query, removing stopwords.""" + tokens = _WORD_RE.findall(text.lower()) + filtered = [t for t in tokens if t not in _STOPWORDS] + # If everything was a stopword, fall back to the original tokens + return filtered if filtered else tokens + + +class DocsIndex: + """In-memory keyword index over documentation sections.""" + + def __init__(self, kb_path: Path | None = None): + if kb_path is None: + kb_path = Path(__file__).parent / "patchworks-knowledge-base.md" + self.sections = _load_sections(kb_path) + # inverted index: token -> set of section indices + self._index: Dict[str, set] = {} + # title tokens get a bonus so title matches outrank body mentions + self._title_tokens: Dict[int, set] = {} + for idx, sec in enumerate(self.sections): + title_toks = set(_tokenize(sec["title"])) + self._title_tokens[idx] = title_toks + tokens = title_toks | set(_tokenize(sec["content"])) + for tok in tokens: + self._index.setdefault(tok, set()).add(idx) + + # ------------------------------------------------------------------ + def search(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]: + """ + Return the most relevant sections for *query*. + + Scoring: each query token that appears in a section scores 1 point. + Substring matches (token starts with a query token) score 0.5. + Results are returned highest-score-first. + """ + q_tokens = _tokenize_query(query) + if not q_tokens: + return [] + + scores: Dict[int, float] = {} + + for qt in q_tokens: + # exact match + for idx in self._index.get(qt, set()): + # title matches score higher + bonus = 2.0 if qt in self._title_tokens.get(idx, set()) else 1.0 + scores[idx] = scores.get(idx, 0) + bonus + + # prefix / substring match (cheap fuzzy) + for tok, idxs in self._index.items(): + if tok != qt and (tok.startswith(qt) or qt.startswith(tok)): + for idx in idxs: + title_toks = self._title_tokens.get(idx, set()) + bonus = 1.0 if tok in title_toks else 0.5 + scores[idx] = scores.get(idx, 0) + bonus + + ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True) + results: List[Dict[str, Any]] = [] + for idx, score in ranked[:max_results]: + sec = self.sections[idx] + results.append({ + "title": sec["title"], + "source_url": sec["source_url"], + "score": round(score, 2), + "content": sec["content"][:2000], # truncate for MCP response + }) + return results + + +# Singleton for the server to import +_index: DocsIndex | None = None + +def get_index() -> DocsIndex: + global _index + if _index is None: + _index = DocsIndex() + return _index diff --git a/mcp_server_instructions.md b/mcp_server_instructions.md new file mode 100644 index 0000000..c797e23 --- /dev/null +++ b/mcp_server_instructions.md @@ -0,0 +1,184 @@ +# Patchworks MCP Server - AI Assistant Instructions + +## Flow Creation Optimization + +When handling flow creation requests, follow these efficiency rules to minimize tool calls: + +### Direct Flow Creation Rules + +**WHEN USER REQUESTS FLOW CREATION:** +- User says: "create a flow", "make a flow", "new flow", "create a process flow" +- **DO NOT** search past conversations first +- **DO NOT** search documentation first +- **DO NOT** call conversation_search or recent_chats +- **GO DIRECTLY** to `create_process_flow_from_json` + +**DEFAULT FLOW TEMPLATE:** +Use this minimal structure immediately when creating flows: + +```json +{ + "metadata": { + "company_name": "MCP Created", + "flow_name": "[USER'S FLOW NAME]", + "exported_at": "[CURRENT_TIMESTAMP]", + "exported_by": "claude-mcp", + "import_summary": { + "setup_required": { + "connectors_needing_config": 0, + "auth_implementations_needing_credentials": 0, + "variables_needing_values": 0 + }, + "dependencies": [], + "imported_resources": { + "systems_imported": 0, + "flow_steps": 1, + "endpoints": 0, + "connectors": 0 + }, + "next_steps": [] + } + }, + "flow": { + "name": "[USER'S FLOW NAME]", + "description": "[DESCRIPTION OR 'Flow created via MCP']", + "is_enabled": false, + "versions": [ + { + "flow_name": "[USER'S FLOW NAME]", + "flow_priority": 3, + "iteration": 1, + "status": "Draft", + "is_deployed": false, + "is_editable": true, + "has_callback_step": false, + "steps": [ + { + "id": 1, + "type": "trigger", + "name": "Trigger", + "description": "Default hourly trigger", + "config": { + "schedule_type": "cron", + "cron_expression": "0 * * * *" + }, + "position": { + "x": 100, + "y": 100 + } + } + ], + "connections": [] + } + ] + }, + "systems": [], + "scripts": [], + "dependencies": [] +} +``` + +### When to Search Documentation + +**ONLY** search documentation if: +- User asks "how do I create a flow?" (informational) +- User asks about flow structure or requirements +- User encounters an error and needs help understanding it +- User asks about specific flow features or capabilities + +**DO NOT** search documentation for direct action requests like "create a flow called X" + +### When to Search Past Conversations + +**ONLY** search past conversations if: +- User explicitly references past work: "like the flow we made yesterday" +- User asks about previous flows by name without creating new ones +- User asks for flow modifications based on previous discussions + +**DO NOT** search past conversations for new flow creation requests + +## Failure Investigation + +When a user asks "why did X fail?", "what went wrong?", or similar: +- **Use `investigate_failure` as your FIRST tool.** It resolves the flow, finds the failed run, + summarises logs, downloads payloads, and follows the alert chain — all in one call. +- Only fall back to individual tools if `investigate_failure` doesn't return enough detail. + +When a user asks "what payload did we send?" or "show me the payload": +- **Use `get_run_payloads`** with the run ID. Optionally filter by step_name (e.g. "Catch"). + +### Alert / notification flows +- Flows like "Slack Failure Alert" are triggered when another flow fails. +- The important information is in the *originating* flow, not the alert flow itself. +- `investigate_failure` follows this chain automatically via the catch-route payload. + +## Tool Call Budget Guidelines + +The tool call budget is now 10 iterations per request. For Slack and other constrained +environments, still aim for efficiency: + +### 1-2 Tool Calls (Preferred for simple tasks) +- Create flow with default settings +- List flows +- Get flow status +- Start a flow +- **Investigate a failure** (use `investigate_failure` — 1 call does it all) +- **Get payloads** (use `get_run_payloads` — 1 call) + +### 3-4 Tool Calls (For moderate complexity) +- Create flow + verify it was created +- Troubleshoot a specific flow run +- Search docs + answer question + +### 5-10 Tool Calls (Complex tasks) +- Triage multiple failures +- Deep investigation of flow issues +- Comparative analysis across multiple flows + +## Response Patterns for Flow Creation + +### ✅ GOOD (Efficient) +``` +User: "Create a flow called Order Sync" +Assistant: [Immediately calls create_process_flow_from_json] +Response: "Created 'Order Sync' flow (ID: 42) with hourly trigger..." +Total tool calls: 1 +``` + +### ❌ BAD (Inefficient) +``` +User: "Create a flow called Order Sync" +Assistant: [Calls conversation_search for "flow"] +Assistant: [Calls search_docs for "create flow"] +Assistant: [Calls recent_chats to check history] +Assistant: [Finally calls create_process_flow_from_json] +Response: "I've created..." +Total tool calls: 4+ (EXCEEDS SLACK LIMIT) +``` + +## Default Flow Settings + +When user doesn't specify: +- **Priority:** 3 (Normal) +- **Schedule:** `0 * * * *` (hourly) +- **Enabled:** false (Draft mode) +- **Trigger Type:** Cron schedule + +Common schedule requests: +- "every hour" → `0 * * * *` +- "every 15 minutes" → `*/15 * * * *` +- "daily" → `0 0 * * *` +- "twice a day" → `0 0,12 * * *` +- "weekdays at 9am" → `0 9 * * 1-5` + +## Error Handling + +If flow creation fails: +1. Show the error message to the user +2. ONLY THEN search docs if the error is unclear +3. Suggest fixes based on the error +4. Offer to retry with corrections + +## Summary + +**Key Principle:** Assume the user wants immediate action, not research. Only search/investigate when explicitly asked or when troubleshooting errors. diff --git a/patchworks-knowledge-base.md b/patchworks-knowledge-base.md new file mode 100644 index 0000000..402cbc8 --- /dev/null +++ b/patchworks-knowledge-base.md @@ -0,0 +1,784 @@ +# Patchworks Product Documentation Knowledge Base + +--- + +## 1. Getting Started + +**Source:** https://doc.wearepatchworks.com/product-documentation/getting-started/getting-started-introduction + +After registering and logging into the Patchworks dashboard, users can begin syncing data between business systems using two approaches: the traditional services method or the newer process flows system. + +**User Timeline Distinction:** +- Accounts created before July 2023 use services and may optionally upgrade to process flows by contacting their Customer Success Manager or emailing customersuccess@wearepatchworks.com +- Accounts created after July 2023 automatically use process flows + +### Process Flows +Process flows represent an advanced tool for defining flexible data exchange workflows between connector instances. The system employs a drag-and-drop interface where users position shapes on a canvas and configure them according to their requirements. The documentation recommends consulting the Patchworks quickstart guide for foundational knowledge, followed by reviewing the dedicated process flows section for comprehensive details. + +### Services (Legacy) +Existing customers familiar with the legacy approach use services to exchange data between system connectors. Those preferring to continue with this method can reference the services documentation section rather than migrating to process flows. + +### Sub-Topics +- Core subscription tiers +- Key concepts & terminology +- Multi-language support +- Patchworks quickstart guide +- Patchworks infrastructure + +--- + +## 2. Registration + +**Source:** https://doc.wearepatchworks.com/product-documentation/registration/registering-for-a-patchworks-account + +Upon registering, users receive a dashboard instance with complete access during a 14-day trial period. + +### Registration Process + +1. **Request a trial account** by clicking the provided link to access Patchworks' free trial offer page. +2. **Submit request details** by completing the trial request form with necessary information. +3. **Schedule a meeting** with the Patchworks Sales team to book an available time slot. +4. **Enter contact information** by completing a form with company details. The email provided becomes the Patchworks point of contact for the company. + +### After Registration +Following the sales team meeting, a trial account is provisioned and login credentials are sent to the registered email address. By default, this account has admin-level permissions. + +User accounts within Patchworks are assigned roles that determine dashboard access levels, which may vary based on the active subscription tier. + +### Multi-Company Profiles +Patchworks partners managing multi-company profiles may add linked company profiles after their primary company profile is established. + +### Additional Registration Topics +- Password control +- Two-factor authentication (2FA) +- SSO (Single Sign-On) +- Azure AD / Entra +- Okta +- PingOne + +--- + +## 3. Core Subscription Tiers + +**Source:** https://doc.wearepatchworks.com/product-documentation/getting-started/core-subscription-tiers + +Patchworks offers multiple subscription tiers designed to accommodate different organizational needs, ranging from basic functionality to advanced data flow capabilities. + +### Available Tiers + +| Tier | Description | +|------|-------------| +| **Trial** | 15-day free period with complete feature access following account registration | +| **Blueprint Store** | For users accessing pre-built blueprints exclusively; access is primarily read-only | +| **Standard** | Full access to standard features; advanced capabilities are view-only; limited connectors/flows | +| **Professional** | Full access to standard and advanced features; higher limits for connectors and flows | +| **Custom** | Comprehensive access with customized limits tailored to specific organizational requirements | + +### Key Feature Allowances + +| Feature | Trial | Blueprint Store | Standard | Professional | Custom | +|---------|-------|-----------------|----------|--------------|--------| +| Deployed connectors | 2 | Read-only | 2 | 4 | Custom | +| Active process flows | 2 | Limited | 10 | 20 | Custom | +| Monthly operations | 10,000 | 150,000 | 250,000 | 500,000 | Custom | +| Concurrent flows | 1 | 10 | 10 | 20 | 30+ | +| Webhooks/minute | 2 | Limited | 0 | 120 | 120+ | + +### Advanced Features +Professional and certain Standard users (with add-ons) access: cache management, de-duplication, custom scripts, webhooks, event connectors, and the Patchworks API. + +### Bolt-On Enhancements +Organizations can purchase additional capabilities including advanced features, connector additions, process flow expansions, webhook capacity increases, callback functionality, partner features, and enhanced rate limits. + +### Allowance Refresh Cycles +Monthly allowances reset at the calendar month's beginning, while daily allowances reset every 24 hours. + +--- + +## 4. Patchworks Quickstart Guide + +**Source:** https://doc.wearepatchworks.com/product-documentation/getting-started/patchworks-quickstart-guide + +Patchworks offers two implementation paths for new clients: +1. **Custom integrations** with Patchworks team assistance for tailored solutions +2. **Self-serve integrations** through the Patchworks dashboard + +### Step 1: Registration +Users can register at https://app.wearepatchworks.com/register using either Google sign-in or username/password credentials. + +**Partner Note:** Patchworks partners managing multiple company profiles should have the partner features bolt-on enabled, allowing creation of separate company profiles for managed organizations. + +### Step 2: Company Setup +Standalone companies require no additional setup. Organizations needing to manage linked companies (with partner features enabled) can establish separate company profiles from their primary account. + +### Step 3: User Setup +Initial registration creates an admin account by default. Admin users can create additional team member accounts with either: +- **Admin privileges** for creating and managing process flows +- **User permissions** for view-only access + +### Step 4: Flow Setup +Two approaches exist for creating process flows: + +**Auto Setup via Blueprints:** A blueprint includes everything needed to sync data between two systems -- connectors, instances, process flows, scripts, and cross-reference lookups. Blueprints purchased from the Patchworks store come with installation instructions and are ready for testing and deployment. + +**Manual Setup:** Organizations manually add connectors, instances, flows, scripts, and lookups. The Patchworks marketplace offers prebuilt components available for installation or customization. Organizations lacking prebuilt connectors can use the connector builder to develop custom solutions. + +### Step 5: Day-to-Day Management +Active process flows execute automatically according to defined trigger settings. Users can alternatively run a process flow manually, with instant feedback and real-time logging. Detailed run logs provide complete data oversight and can be reviewed retrospectively. + +--- + +## 5. Company Setup (Company Profiles) + +**Source:** https://doc.wearepatchworks.com/product-documentation/company-management/about-company-profiles + +Upon creating a Patchworks account, your organization receives a company profile where you can access and update foundational organizational information including name and contact details, plus manage user accounts. + +The individual who initially registers the company automatically receives administrator access to the dashboard -- this is the highest level of access that can be associated with a company profile. + +For agencies or partner organizations managing multiple company accounts, the functionality operates differently -- consult the dedicated Multi-company profiles documentation for specifics. + +### Sub-Topics +- Accessing your company profile +- Adding & managing company profile banners +- Multi-company profiles +- Company insights + +--- + +## 6. Users, Roles & Permissions + +**Source:** https://doc.wearepatchworks.com/product-documentation/users-roles-and-permissions/roles-and-permissions-summary + +### Core Concept +The Patchworks platform implements a role-based access control system with four user roles: **Administrator**, **Manager**, **User**, and **Read-only**. A critical principle: "tier trumps role" -- meaning subscription tier determines actual feature availability regardless of assigned permissions. + +### Role Assignment +New account registrants automatically receive the Administrator role, with authority to create additional users and assign roles. Typically one administrator per organization; multiple administrator accounts require support approval. + +### Permission Framework + +**Company Management:** +- Administrators control company profile name and deletion +- Administrators and Managers can update contact information and manage banner messages +- All four roles can view company profiles + +**User Administration:** +- Administrators and Managers create and manage users with lower roles +- Only Patchworks Support can create Administrator-level accounts or promote/demote Administrator users +- Managers cannot create other Manager accounts -- only Administrators can +- All roles can enable/disable their own multi-factor authentication + +**Marketplace & Resources:** +- Administrators and Managers install blueprints, connectors, and process flows +- Only Administrators build blueprints +- All roles can browse marketplace content + +**Process Flows & Development:** +- Administrators and Managers exclusively create, modify, and delete process flows +- Administrators and Managers enable/deploy flows +- All roles can view existing flows + +**Data Management:** +- Administrators and Managers manage custom scripts, cross-reference lookups, and caches +- All roles can view these resources + +**API Access:** +- Administrators and Managers generate and manage API keys +- All roles can access the API itself + +### Additional User Management Topics +- Viewing all users for your company profile +- Creating a new user account for your company profile +- Updating general details for an existing user account +- Updating the role for an existing user account +- Triggering a password reset for another user +- Managing your own user account +- Managing team members & users for multi-company profiles + +--- + +## 7. Marketplace + +**Source:** https://doc.wearepatchworks.com/product-documentation/marketplace/the-patchworks-marketplace + +### Overview +The Patchworks marketplace functions as a centralized hub where users can discover and deploy various pre-built resources to enhance their dashboard integration capabilities. + +### Available Resources +The marketplace offers five main categories of installable resources: +- Connectors +- Process flows +- Custom scripts +- Cross-reference lookups +- Blueprints + +### How to Access +1. Sign into the Patchworks dashboard at app.wearepatchworks.com/login +2. Select "marketplace" from the left navigation menu + +### Additional Features +Users with appropriate permissions can access a private marketplace option for personalized resources. + +### Sub-Topics +- Marketplace blueprints +- Marketplace connectors +- Marketplace process flows +- Marketplace scripts +- Marketplace cross-reference lookups +- The notification centre +- Public marketplace submissions (apps/blueprints and connectors) +- Private marketplaces (accessing, uploading resources) + +--- + +## 8. Blueprints + +**Source:** https://doc.wearepatchworks.com/product-documentation/blueprints + +### Core Concepts +A blueprint includes everything needed to sync data between two systems. All connectors used in process flows are installed with the blueprint. Prior to installation, you can choose to add required connector instances or install the connectors and add instances later. To use connectors in process flows, you must add an instance of each -- this is where you provide authentication credentials for the associated third-party system. + +### Blueprint Installation +Once installed, all blueprint components (connectors, process flows, scripts, etc.) are added to the relevant area of your Patchworks dashboard. When a blueprint is installed, its process flows are disabled and set to a draft status. When ready, you should enable and deploy any process flows that you want to use. + +Blueprints are added to your dashboard marketplace within 24 hours of purchase. Your Patchworks subscription tier determines the number of process flows and connectors that you can deploy. + +### Available Pre-Built Blueprints +- Lightspeed X-Series & Shopify +- SEKO Logistics & Shopify +- Shopify & Brightpearl (15+ process flows covering locations, products, orders, inventory, fulfillment, payments, pricing) +- Shopify & Descartes Peoplevox +- Shopify & NetSuite (13+ process flows, 6-stage installation guide) +- Shopify & Virtualstock Supplier (8-stage installation guide) +- Veeqo & TikTok + +### Blueprint Management +- Private blueprint management (creation, installation, updates, versions, deletion) +- Blueprint rollout procedures + +--- + +## 9. Connectors & Instances + +**Source:** https://doc.wearepatchworks.com/product-documentation/connectors-and-instances/connectors-and-instances-introduction + +### Connectors +A connector is a generic integration of a third-party business system/application. It contains everything needed "under the hood" (endpoints, authentication methods, etc.) to sync data from/to the associated application. When you install a connector, you are installing a package of generic configuration and setup. You only need to install a given connector once. After that, you can add as many instances of it as you need for use in your process flows. + +The Patchworks development team maintains all prebuilt connectors in the marketplace. If you have installed a connector, updates may become available in the marketplace -- you can decide if/when you apply them. + +### Instances +An instance is the mechanism used to configure a connector for your own use in process flows. Instances are added to process flows via the connection shape. Every instance requires authentication credentials that allow you to access the associated third-party application. An instance of a connector is unique to your company, personalized with your own credentials and settings. + +Typically, you will create one instance for each set of credentials that you have for a given connector that you want to use in process flows. + +### The Relationship +If you update an installed connector, that update is applied to all associated instances automatically. + +### Practical Examples +- **Simple setup**: One UK Shopify store syncing orders to NetSuite requires one Shopify connector instance and one NetSuite connector instance +- **Complex setup**: Three Shopify stores (UK, EU, US) each with separate credentials require three Shopify connector instances but only one NetSuite connector instance + +### Event Connectors +Event connectors are a different sort of connector, used to configure listeners for message brokers such as RabbitMQ. + +### Prebuilt Connectors +The documentation lists over 150 prebuilt connectors covering major platforms across e-commerce, logistics, marketing, accounting, and communication categories (Shopify, BigCommerce, Adobe Commerce, NetSuite, etc.). + +### Connector Management +- Accessing, installing, updating, and removing connectors +- Instance management: accessing, adding, updating, and removing instances + +--- + +## 10. Process Flows + +**Source:** https://doc.wearepatchworks.com/product-documentation/process-flows + +### Overview +In their simplest form, process flows receive data from one third-party application and send it to another, perhaps with data manipulation in between. Process flows allow you to build highly complex flows with multiple routes and conditions. + +### Key Components + +**Trigger:** Every process flow starts with a trigger that determines when the flow should run. Trigger options are defined using the trigger shape. When a new process flow is created, a trigger shape is added with an hourly schedule by default. Trigger shapes cannot be moved or deleted, but settings can be changed. + +**Data Source:** A data source is defined by adding a connection shape and selecting a connector instance and endpoint. + +**Filters:** Optional refinement layer using the filter shape to narrow the payload before further processing. + +**Custom Scripts:** Advanced custom coding capability for complex payload manipulation using the script shape. Typically unnecessary for standard integrations. + +**Field Mappings:** Maps source data fields to destination locations with optional transformation functions or custom scripts for value manipulation. + +**Data Destination:** Specifies the receiving application with installed connectors and configured instances. + +### The Process Flow Canvas +The canvas is where you build and test process flows visually. This is where you define if, when, what, and how data is synced. + +### Process Flow Shapes + +**Standard shapes:** assert, branch, connector, filter, map, notify, route, split, trigger, flow control + +**Advanced shapes:** cache, de-dupe, script, callback + +### Dynamic Variables +- Payload variables +- Metadata variables +- Flow variables -- provide the ability to define variables at the process flow level and reference them throughout the entire flow + +### Transform Functions +Extensive coverage of transformation capabilities including array, date, number, string, and other functions for field mapping, plus specialized functions like cache lookup, boolean casting, and custom transformations. + +### Management & Operations +- Deployment strategies (with/without virtual environments) +- Flow enablement and configuration +- Process flow labels and duplication +- Error handling and logging +- Cross-reference lookup integration +- Email notifications for failed process flow runs + +### Process Flow Versioning +Process flows support three version states: draft, deployed, and inactive. + +### Troubleshooting +- Common issues like editing problems, runtime failures +- Large payload handling +- Webhook connector errors +- System offline scenarios + +--- + +## 11. Virtual Environments + +**Source:** https://doc.wearepatchworks.com/product-documentation/virtual-environments/about-virtual-environments + +### Core Concept +Virtual environments enable enterprises to manage multiple stores and brands within a single Patchworks account without duplicating process flows across testing, staging, and production environments. + +### The Problem Addressed +Organizations operating multiple storefronts face significant management challenges. For example, an international retailer with five country-specific Shopify stores (each requiring sandbox and live versions) would need 10 connector instances. Combined with five essential process flows, this creates 50 separate flows requiring individual updates. + +### The Solution +Rather than maintaining duplicate flows, users create "master" process flows and apply environment-specific overrides through virtual environments. Each virtual environment is configured with the required overrides so components get replaced during deployment. + +### Configurable Replacements +Virtual environments support overrides for: +- Connector instances +- Data pools +- Cross-reference lookups +- Scripts +- Company caches +- Flow variables +- Flow queue priority + +### Deployment at Scale +Single flows deploy directly from the canvas, while multiple flows benefit from "packages" that bundle process flow versions with target environments, enabling batch deployments in one operation. + +### Flow Versioning with Virtual Environments +- A process flow will only ever have ONE draft version +- Only ONE version of a process flow can be deployed to a given virtual environment +- Multiple versions of the same process flow can be deployed to different virtual environments +- A flow version can be deployed to a single virtual environment, to multiple virtual environments, or to no virtual environment + +### Availability +The feature is included across all subscription tiers, with a default allowance of two virtual environments. Additional environments require contacting the sales team. + +--- + +## 12. General Settings + +**Source:** https://doc.wearepatchworks.com/product-documentation/general-settings/general-settings-introduction + +As a company administrator, you have access to various general settings for managing your organization's profile. + +### Audit Logs +The audit logs feature captures significant activities and changes within your company's dashboard. Users holding client admin credentials can access these logs to investigate historical events. + +**Accessing:** Navigate to settings menu on the left sidebar and select the audit logs option. + +**Event Organization:** Events are organized chronologically by date with color-coded headers: +- **Green**: Items added or created +- **Orange**: Items modified +- **Red**: Items removed + +**Searching:** A search function allows locating specific entries by person's name, keywords, or ID numbers. + +### Notification Groups +Additional configuration for notification management is available under this section. + +--- + +## 13. Connector Builder + +**Source:** https://doc.wearepatchworks.com/product-documentation/developer-hub/connector-builder + +### Overview +Patchworks enables users to install and utilize prebuilt connectors from its marketplace. However, the platform also provides a connector builder for scenarios where no prebuilt solution exists -- such as integrating custom in-house systems or non-eCommerce applications. + +The connector builder allows you to build your own connectors, which can then be used in exactly the same way as the prebuilt connectors found in the Patchworks marketplace. Custom-built connectors remain private to your organization. + +### Target Audience +This tool is designed for individuals with API and data structure knowledge who want to integrate applications without requiring coding expertise. If you are comfortable working with APIs and data structures, you can use the connector builder to integrate any application with an API. + +### Postman Importer +For those familiar with Postman, the platform offers a Postman importer feature that can automatically generate connectors from existing collections, which can then be customized as needed. + +### Documentation Structure +- Accessing the connector builder +- Building your own connector (includes authentication methods such as SOAP authentication, OAuth, etc.) +- Maintaining your own connectors + +--- + +## 14. Custom Scripting + +**Source:** https://doc.wearepatchworks.com/product-documentation/developer-hub/custom-scripting + +### Overview +Patchworks facilitates data integration between source and destination systems through field mappings and transformation functions. When standard tools prove insufficient, the platform offers custom scripting capabilities for advanced data manipulation. + +### Integrated Development Environment +The custom script editor includes IntelliSense and AI assistance. The AI integration knows about expected keys and value types (payload, variables, meta, etc.), so generated scripts will be in a form that is ready to use in process flows. + +### Implementation Methods + +**1. Process Flow Integration:** +- Script shapes (executing scripts at any workflow point) +- Map shapes (applying script transforms to fields before destination mapping) + +**2. Connector Setup:** +- Endpoint pagination scripting +- Pre and post-authentication scripts +- Pre and post-request scripts + +### Supported Programming Languages +- C# 8.0 +- Go (1.18 & 1.23) +- Rust 1.8.2 +- JavaScript (Node 18) +- PHP (8.1 & 8.2) +- Python 3 +- Ruby 3 + +### Available Libraries +The platform includes language-specific packages such as jsrsasign for JavaScript, requests for Python, phpseclib for PHP, and System.Xml.ReaderWriter for C#. Additional libraries can be embedded or requested from support. + +--- + +## 15. Patchworks API + +**Source:** https://doc.wearepatchworks.com/product-documentation/developer-hub/patchworks-api + +### Introduction +Patchworks functions as an API-driven platform aligned with MACH Alliance principles. Every dashboard action corresponds to an API request. The Core API is accessible through a public Postman collection for developers. + +### Access Requirements +API availability depends on your subscription tier within Patchworks Core's service levels. + +### Documentation Sections +1. **Core API Postman collection** -- The public collection for API requests +2. **Core API spotlights** -- Featured API information and use cases +3. **Core API general information** -- Foundational details about the Core API + +--- + +## 16. Patchworks MCP (Model Context Protocol) + +**Source:** https://doc.wearepatchworks.com/product-documentation/developer-hub/patchworks-mcp + +### Introduction +The Patchworks MCP server enables AI assistants like Claude, Gemini, or ChatGPT to interact with Patchworks directly. Users can triage issues, generate reports, and execute flows using natural language. This capability benefits merchants, partners, and developers by transforming integration workflows. + +### What is an MCP Server? +MCP (Model Context Protocol) represents an open protocol enabling secure, standardized connections between AI assistants and external data sources or tools. + +An MCP server functions as a bridge between your AI assistant and business systems like Patchworks, providing real-time data access and action capabilities with controlled permissions. The architecture involves: +- **Client-server model**: AI agents (clients) make requests to MCP servers +- **Tools and resources**: Servers expose tools (get flow runs, summarize failures, triage issues) and resources (documentation, databases) +- **Extended capabilities**: AI agents use these elements to expand functionality beyond core language understanding + +### Pre-loaded Tools +After local installation, users can access ten pre-loaded tools supporting: +- Data tracking through the platform +- Automating customer inquiries like "Where is my order?" +- Rapid report generation from logs +- Error identification requiring intervention +- Instant access to logs and payloads +- Advanced root cause analysis +- Direct flow triggering +- System-level tools including "List all flows," "Summarise failed run," and "Download payloads" + +### Example Use Cases + +| Team | Use Case | +|------|----------| +| Customer Support | Show me all failed Shopify to NetSuite flows from last night and explain why | +| Operations | Re-run failed flows from yesterday | +| Development | Give me payload samples going into Order Flow X | + +### Key Benefits +- **AI-ready iPaaS**: Integrations become conversational and intelligent +- **Faster troubleshooting**: Automate triage and identify solutions +- **Customizable**: Add tools alongside pre-loaded options +- **Safe & secure**: Per-tenant isolation, role-based access, auditable tool calls +- **Future-proof**: Works with Claude, Gemini, ChatGPT, and MCP-compatible clients + +### Implementation Options +Patchworks MCP is available for local installation immediately. A hosted solution is currently in development. + +Product documentation can also be integrated with AI assistants via a separate MCP server resource. + +--- + +## 17. Stockr + +**Source:** https://doc.wearepatchworks.com/product-documentation/patchworks-bolt-ons/stockr/stockr-overview + +### Introduction +Stockr functions as a Patchworks tool designed to manage inventory across multiple Shopify stores that share a common stock pool. The system maintains real-time synchronization of stock levels across all connected stores as orders arrive. When inventory for any item becomes depleted, all linked stores receive simultaneous updates, preventing overselling situations. + +The platform is offered as an add-on service for Patchworks. + +### Stockr Dashboards +Users of Stockr receive access to a unique URL providing multiple views within a DataDog dashboard interface. + +Through the Patchworks dashboard, users can access the Stockr summary feature, which offers comprehensive visibility of processed transactions and associated expenses across specified date ranges. Users have the option to export transaction data when needed. + +--- + +## URL Reference Guide + +The correct URL structure for Patchworks documentation is `https://doc.wearepatchworks.com/product-documentation/{section}/{page}`. + +| Topic | Correct URL | +|-------|-------------| +| Getting Started | /product-documentation/getting-started/getting-started-introduction | +| Quickstart Guide | /product-documentation/getting-started/patchworks-quickstart-guide | +| Subscription Tiers | /product-documentation/getting-started/core-subscription-tiers | +| Registration | /product-documentation/registration/registering-for-a-patchworks-account | +| Company Profiles | /product-documentation/company-management/about-company-profiles | +| Users & Roles | /product-documentation/users-roles-and-permissions/roles-and-permissions-summary | +| Marketplace | /product-documentation/marketplace/the-patchworks-marketplace | +| Blueprints | /product-documentation/blueprints | +| Connectors & Instances | /product-documentation/connectors-and-instances/connectors-and-instances-introduction | +| Process Flows | /product-documentation/process-flows | +| First Process Flow | /product-documentation/process-flows/building-process-flows/approaching-your-first-process-flow | +| Virtual Environments | /product-documentation/virtual-environments/about-virtual-environments | +| General Settings | /product-documentation/general-settings/general-settings-introduction | +| Connector Builder | /product-documentation/developer-hub/connector-builder | +| Custom Scripting | /product-documentation/developer-hub/custom-scripting | +| Patchworks API | /product-documentation/developer-hub/patchworks-api | +| Patchworks MCP | /product-documentation/developer-hub/patchworks-mcp | +| Stockr | /product-documentation/patchworks-bolt-ons/stockr/stockr-overview | + +--- + +# Creating Process Flows via MCP + +## Flow Creation Methods + +The Patchworks MCP server provides two methods for creating process flows: + +1. **create_process_flow_from_prompt** - Natural language flow creation (simpler, less control) +2. **create_process_flow_from_json** - Full JSON structure import (complete control, recommended) + +## Method 1: create_process_flow_from_json (Recommended) + +This method requires a complete flow export structure. Use this when you need full control over the flow definition. + +### Required JSON Structure + +The flow import JSON must contain these **five required top-level keys**: + +```json +{ + "metadata": { ... }, + "flow": { ... }, + "systems": [], + "scripts": [], + "dependencies": [] +} +``` + +### Minimal Working Example + +Here's a minimal flow with just a trigger that runs hourly: + +```json +{ + "metadata": { + "company_name": "Your Company Name", + "flow_name": "Your Flow Name", + "exported_at": "2026-02-17T14:30:00+00:00", + "exported_by": "your-identifier", + "import_summary": { + "setup_required": { + "connectors_needing_config": 0, + "auth_implementations_needing_credentials": 0, + "variables_needing_values": 0 + }, + "dependencies": [], + "imported_resources": { + "systems_imported": 0, + "flow_steps": 1, + "endpoints": 0, + "connectors": 0 + }, + "next_steps": [] + } + }, + "flow": { + "name": "Your Flow Name", + "description": "Description of what this flow does", + "is_enabled": false, + "versions": [ + { + "flow_name": "Your Flow Name", + "flow_priority": 3, + "iteration": 1, + "status": "Draft", + "is_deployed": false, + "is_editable": true, + "has_callback_step": false, + "steps": [ + { + "id": 1, + "type": "trigger", + "name": "Trigger", + "description": "Default hourly trigger", + "config": { + "schedule_type": "cron", + "cron_expression": "0 * * * *" + }, + "position": { + "x": 100, + "y": 100 + } + } + ], + "connections": [] + } + ] + }, + "systems": [], + "scripts": [], + "dependencies": [] +} +``` + +### Key Structure Requirements + +#### 1. metadata (Required) + +Must contain: +- `company_name` (string) - Name of the company +- `flow_name` (string) - Name of the flow +- `exported_at` (string) - ISO 8601 timestamp +- `exported_by` (string) - Identifier of creator +- `import_summary` (object) - Summary of import requirements + - `setup_required` (object) - Configuration needs + - `connectors_needing_config` (integer) + - `auth_implementations_needing_credentials` (integer) + - `variables_needing_values` (integer) + - `dependencies` (array) - Flow dependencies + - `imported_resources` (object) - Resource counts + - `systems_imported` (integer) + - `flow_steps` (integer) + - `endpoints` (integer) + - `connectors` (integer) + - `next_steps` (array) - Setup instructions + +#### 2. flow (Required) + +Must contain: +- `name` (string) - Flow name +- `description` (string) - Flow description +- `is_enabled` (boolean) - Whether flow is enabled +- `versions` (array) - Array of flow versions, each containing: + - `flow_name` (string) - Name of the flow + - `flow_priority` (integer) - Priority 1-5 (1 is highest) + - `iteration` (integer) - Version iteration number + - `status` (string) - "Draft" or deployment status + - `is_deployed` (boolean) - Deployment status + - `is_editable` (boolean) - Edit permissions + - `has_callback_step` (boolean) - Whether flow has callbacks + - `steps` (array) - **Required: At least one step** + - Each step must have: `id`, `type`, `name`, `position`, and type-specific `config` + - `connections` (array) - Connections between steps (can be empty) + +#### 3. systems (Required) + +Array of connector system configurations. Can be empty `[]` for simple flows. + +#### 4. scripts (Required) + +Array of custom scripts. Can be empty `[]` for flows without custom scripts. + +#### 5. dependencies (Required) + +Array of flow dependencies. Can be empty `[]` for flows without dependencies. + +### Common Step Types + +- `trigger` - Flow trigger (schedule, webhook, event) +- `connector` - Data source or destination connector +- `map` - Field mapping between source and destination +- `filter` - Filter data based on conditions +- `script` - Custom script execution +- `branch` - Conditional branching logic +- `route` - Route data to different paths +- `split` - Split data into batches +- `cache` - Cache management +- `de-dupe` - Data de-duplication + +### Priority Levels + +- **1** - Highest priority +- **2** - High priority +- **3** - Normal priority (default) +- **4** - Low priority +- **5** - Lowest priority + +### Cron Schedule Examples + +- `0 * * * *` - Every hour at minute 0 +- `*/15 * * * *` - Every 15 minutes +- `0 0 * * *` - Daily at midnight +- `0 0 * * 0` - Weekly on Sunday at midnight +- `0 0 1 * *` - Monthly on the 1st at midnight +- `0 */6 * * *` - Every 6 hours +- `0 9 * * 1-5` - Weekdays at 9 AM + +## Method 2: create_process_flow_from_prompt + +Creates a basic flow skeleton from a natural language description. + +### Usage Example + +``` +Prompt: "create a process flow for Shopify to NetSuite orders" +Priority: 3 (default) +Schedule: "0 * * * *" (hourly) +Enable: false (default) +``` + +This method is simpler but provides less control over the flow structure. For production flows or complex requirements, use `create_process_flow_from_json` instead. + +## Important Notes + +- All five top-level keys (`metadata`, `flow`, `systems`, `scripts`, `dependencies`) **must be present**, even if arrays are empty +- The `metadata.import_summary` structure is required for validation +- Each flow must have at least one version in the `versions` array +- Each version must have at least one step in the `steps` array +- Step IDs must be unique within the flow +- Position coordinates (x, y) determine where shapes appear on the canvas +- Flows are created in "Draft" status by default (not enabled) +- After creation, flows can be further configured in the Patchworks dashboard + +## Getting Flow Export Structure + +To see a complete flow structure: +1. Use `get_all_flows` to list available flows +2. Export an existing flow from the Patchworks dashboard +3. Use that export as a template for creating new flows via JSON \ No newline at end of file diff --git a/patchworks_client.py b/patchworks_client.py index b21a226..de122b4 100644 --- a/patchworks_client.py +++ b/patchworks_client.py @@ -1,5 +1,6 @@ from __future__ import annotations import os, json, logging, base64 +from datetime import datetime, timezone from typing import Any, Optional, Dict, List, Tuple from pathlib import Path @@ -27,9 +28,27 @@ TOKEN = os.getenv("PATCHWORKS_TOKEN", "") TIMEOUT = float(os.getenv("PATCHWORKS_TIMEOUT_SECONDS", "20")) +# Dashboard base URL for generating deep links to flow runs / flows. +# e.g. https://app.wearepatchworks.com (no trailing slash) +DASHBOARD_URL = os.getenv("PATCHWORKS_DASHBOARD_URL", "https://app.wearepatchworks.com").rstrip("/") + if not CORE_API or not TOKEN: raise RuntimeError("Set PATCHWORKS_CORE_API (or PATCHWORKS_BASE_URL) and PATCHWORKS_TOKEN") +# Commerce Foundation callback URLs (per-operation, configured per deployment) +CF_CALLBACK_ORDERS = os.getenv("PATCHWORKS_CALLBACK_ORDERS", "") +CF_CALLBACK_CUSTOMERS = os.getenv("PATCHWORKS_CALLBACK_CUSTOMERS", "") +CF_CALLBACK_PRODUCTS = os.getenv("PATCHWORKS_CALLBACK_PRODUCTS", "") +CF_CALLBACK_PRODUCT_VARIANTS = os.getenv("PATCHWORKS_CALLBACK_PRODUCT_VARIANTS", "") +CF_CALLBACK_INVENTORY = os.getenv("PATCHWORKS_CALLBACK_INVENTORY", "") +CF_CALLBACK_FULFILLMENTS = os.getenv("PATCHWORKS_CALLBACK_FULFILLMENTS", "") +CF_CALLBACK_RETURNS = os.getenv("PATCHWORKS_CALLBACK_RETURNS", "") +CF_CALLBACK_CREATE_ORDER = os.getenv("PATCHWORKS_CALLBACK_CREATE_ORDER", "") +CF_CALLBACK_UPDATE_ORDER = os.getenv("PATCHWORKS_CALLBACK_UPDATE_ORDER", "") +CF_CALLBACK_CANCEL_ORDER = os.getenv("PATCHWORKS_CALLBACK_CANCEL_ORDER", "") +CF_CALLBACK_FULFILL_ORDER = os.getenv("PATCHWORKS_CALLBACK_FULFILL_ORDER", "") +CF_CALLBACK_CREATE_RETURN = os.getenv("PATCHWORKS_CALLBACK_CREATE_RETURN", "") + # NOTE: # If your gateway expects 'Bearer ', include 'Bearer ' in PATCHWORKS_TOKEN. # Example: @@ -84,6 +103,7 @@ def get_all_flows(page: int = 1, per_page: int = 50, include: Optional[str] = No def get_flow_runs( status: Optional[int] = None, started_after: Optional[str] = None, + flow_id: Optional[int] = None, page: int = 1, per_page: int = 50, sort: Optional[str] = "-started_at", @@ -91,7 +111,7 @@ def get_flow_runs( ) -> Any: """ GET /flow-runs (Core API) - For failures: status=3 + For failures: status=3, partial success: status=5 """ params: Dict[str, Any] = {"page": page, "per_page": per_page} if sort: @@ -100,11 +120,24 @@ def get_flow_runs( params["include"] = include if status is not None: params["filter[status]"] = status + if flow_id is not None: + params["filter[flow_id]"] = flow_id if started_after: params["filter[started_after]"] = started_after r = session.get(_url(CORE_API, "/flow-runs"), params=params, timeout=TIMEOUT) return _handle(r) +def get_flow_run(run_id: str, include: Optional[str] = None) -> Any: + """ + GET /flow-runs/{id} (Core API) + Fetch a single flow run's metadata — useful for resolving flow_id from a run ID. + """ + params: Dict[str, Any] = {} + if include: + params["include"] = include + r = session.get(_url(CORE_API, f"/flow-runs/{run_id}"), params=params, timeout=TIMEOUT) + return _handle(r) + def get_flow_run_logs( run_id: str, per_page: int = 10, @@ -147,10 +180,16 @@ def start_flow(flow_id: str, payload: Optional[Dict[str, Any]] = None) -> Any: """ POST /flows/{id}/start (Start API) Requires PATCHWORKS_START_API = https://start.wearepatchworks.com/api/v1 + + The payload (if provided) is JSON-stringified and sent as + {"payload": ""} — the Start API requires the payload + field to be a string, not a nested object. """ if not START_API: raise RuntimeError("PATCHWORKS_START_API is not set; required for starting flows.") - body = payload or {} + body: Dict[str, Any] = {} + if payload: + body["payload"] = json.dumps(payload) r = session.post(_url(START_API, f"/flows/{flow_id}/start"), data=json.dumps(body), timeout=TIMEOUT) return _handle(r) @@ -226,12 +265,29 @@ def summarise_failed_run(run_id: str, max_logs: int = 50) -> Dict[str, Any]: tail = extracted[-1] highlights.append(f"Last log line: [{tail.get('level')}] {tail.get('message')}") + # Collect payload metadata IDs from logs so callers know payloads are available + available_payloads: List[Dict[str, Any]] = [] + for e in extracted: + pid = e.get("payload_metadata_id") + if pid: + available_payloads.append({ + "payload_metadata_id": pid, + "flow_step_id": e.get("flow_step_id"), + "hint": f"Payload available — call download_payload with ID '{pid}' to retrieve it.", + }) + + # Only include ERROR/FATAL log entries (not the full log dump) to keep + # the tool result small and avoid blowing the token budget on follow-up turns. + error_logs = [e for e in extracted if e.get("level") in ("ERROR", "FATAL")] + return { "run_id": run_id, + "run_log_url": _run_log_url(run_id), "levels": levels, "log_count": len(extracted), "highlights": highlights, - "logs": extracted, # caller can render/inspect + "available_payloads": available_payloads, + "error_logs": error_logs, } def triage_latest_failures( @@ -303,167 +359,425 @@ def import_flow(payload: Dict[str, Any]) -> Any: # ------------------------------------------------------------------------------ -# Commerce Operations Foundation - Query Tools -# Configure in the callback flow URL for your specific account implementation +# Composite / high-level investigation helpers # ------------------------------------------------------------------------------ -def get_orders(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema +def _run_log_url(run_id: str) -> str: + """Build a dashboard deep-link to a flow run's log page.""" + return f"{DASHBOARD_URL}/flow-run-logs/{run_id}" + + +# Maximum number of payloads to download inside investigate_failure to keep +# total execution time short (each download is a blocking HTTP call). +_MAX_PAYLOAD_DOWNLOADS = 2 + + +def _parse_ts(value: str) -> Optional[datetime]: + """Best-effort parse of a timestamp string into a UTC datetime.""" + if not value: + return None + for fmt in ( + "%Y-%m-%dT%H:%M:%S.%fZ", # ISO-8601 with fractional seconds + "%Y-%m-%dT%H:%M:%SZ", # ISO-8601 + "%Y-%m-%dT%H:%M:%S.%f", # without Z + "%Y-%m-%dT%H:%M:%S", # without Z + "%Y-%m-%d %H:%M:%S", # human-friendly (from Slack alert) + ): + try: + return datetime.strptime(value, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + return None + + +def _best_run_by_timestamp( + runs: List[Dict[str, Any]], target: datetime, +) -> Optional[Dict[str, Any]]: + """ + Pick the run whose started_at or finished_at is closest to *target*. + Returns the best-matching run dict, or None if runs is empty. + """ + best_run = None + best_delta = None + for run in runs: + attrs = run.get("attributes", {}) if isinstance(run, dict) else {} + for ts_field in ("finished_at", "started_at"): + ts_val = attrs.get(ts_field) + if not ts_val: + continue + parsed = _parse_ts(str(ts_val)) + if not parsed: + continue + delta = abs((parsed - target).total_seconds()) + if best_delta is None or delta < best_delta: + best_delta = delta + best_run = run + return best_run + + +def investigate_failure( + flow_id: Optional[int] = None, + flow_name: Optional[str] = None, + run_id: Optional[str] = None, + include_payload: bool = False, + failed_at: Optional[str] = None, +) -> Dict[str, Any]: + """ + All-in-one failure investigation. Accepts a flow name/ID or a specific run ID. + Steps: + 1. If only flow_name given, resolve to flow_id via get_all_flows. + 2. Find the most recent failed run for that flow (or use the given run_id). + If *failed_at* is supplied (ISO-8601 or "YYYY-MM-DD HH:MM:SS"), match the + run whose started_at/finished_at is closest to that timestamp — this ensures + we investigate the exact run the alert refers to. + 3. Summarise the failed run (logs, errors, highlights). + 4. Download catch-route payloads to follow the alert chain (always) and + include full payload data in the result only if include_payload is True. + Returns a rich diagnostic object in a single tool call. + """ + result: Dict[str, Any] = {"flow_id": flow_id, "flow_name": flow_name} + + # -- Step 1: resolve flow_name → flow_id if needed ------------------------- + if flow_name and not flow_id: + page = 1 + found = False + while not found: + flows_resp = get_all_flows(page=page, per_page=200) + data = flows_resp.get("data", []) if isinstance(flows_resp, dict) else [] + if not data: + break + for f in data: + attrs = f.get("attributes", {}) if isinstance(f, dict) else {} + name = attrs.get("name", "") + if name.lower() == flow_name.lower(): + flow_id = f.get("id") + result["flow_id"] = flow_id + result["flow_name"] = name + result["flow_enabled"] = attrs.get("is_enabled") + found = True + break + meta = flows_resp.get("meta", {}) + if page >= meta.get("last_page", page): + break + page += 1 + + if not flow_id: + result["error"] = f"Could not find a flow named '{flow_name}'." + return result + + # -- Step 2: find the correct problematic run --------------------------------- + # Try/Catch flows are tricky: the Odoo call can fail with a 422 but the + # overall run finishes as SUCCESS (2) because the catch branch handled it + # (e.g. sent a Slack alert). So we MUST also consider SUCCESS runs when + # a timestamp is provided — it's the only way to find the right one. + # + # Strategy: + # A. When failed_at IS provided → search ALL statuses (3, 5, 2, 1) and + # pick the run closest to that timestamp. + # B. When failed_at is NOT provided → search only 3 and 5 (the old + # behaviour) so we don't surface random successful runs. + target_ts = _parse_ts(failed_at) if failed_at else None + + if not run_id: + # Choose which statuses to search based on whether we have a timestamp + statuses_to_search = (3, 5, 2, 1) if target_ts else (3, 5) + + candidate_runs: List[Dict[str, Any]] = [] + for search_status in statuses_to_search: + runs_resp = get_flow_runs( + status=search_status, flow_id=flow_id, + page=1, per_page=10, sort="-started_at", + ) + data = runs_resp.get("data", []) if isinstance(runs_resp, dict) else [] + candidate_runs.extend(data) + + if candidate_runs: + if target_ts: + # Timestamp-match: pick the run closest to the alert's timestamp + run = _best_run_by_timestamp(candidate_runs, target_ts) + else: + # No timestamp — fall back to most recent (sorted by started_at desc) + run = candidate_runs[0] + + if run: + attrs = run.get("attributes", {}) if isinstance(run, dict) else {} + run_id = run.get("id") + result["run_status"] = attrs.get("status") + result["run_started_at"] = attrs.get("started_at") + result["run_finished_at"] = attrs.get("finished_at") + + if not run_id: + # Widen the search — look for any recent run (no status filter). + runs_resp2 = get_flow_runs( + flow_id=flow_id, page=1, per_page=10, sort="-started_at", + ) + data2 = runs_resp2.get("data", []) if isinstance(runs_resp2, dict) else [] + if data2: + if target_ts: + run = _best_run_by_timestamp(data2, target_ts) + else: + run = data2[0] + if run: + attrs = run.get("attributes", {}) if isinstance(run, dict) else {} + run_id = run.get("id") + result["run_status"] = attrs.get("status") + result["run_started_at"] = attrs.get("started_at") + result["note"] = "No failed/partial-success run found; using closest matching run instead." + + if not run_id: + result["error"] = f"No recent runs found for flow_id={flow_id}." + return result + + result["run_id"] = run_id + result["run_log_url"] = _run_log_url(run_id) + + # -- Step 3: summarise the run ---------------------------------------------- + try: + summary = summarise_failed_run(run_id, max_logs=50) + result["summary"] = summary + except Exception as e: + result["summary_error"] = str(e) + return result + + # -- Step 4: follow the alert/catch chain ----------------------------------- + # ALWAYS attempt to follow the chain by downloading the first payload to + # look for an originating run ID. Alert/catch flows won't have useful error + # detail themselves — the real error is in the originating run. + # The `include_payload` flag only controls whether full payload data is + # included in the response (which costs tokens). + originating_run_id = None + payloads_decoded: List[Dict[str, Any]] = [] + + if summary.get("available_payloads"): + for p_info in summary["available_payloads"][:_MAX_PAYLOAD_DOWNLOADS]: + pid = p_info.get("payload_metadata_id") + if not pid: + continue + try: + ctype, raw = download_payload(pid) + decoded: Any = None + if "json" in ctype.lower(): + try: + decoded = json.loads(raw) + except Exception: + decoded = raw.decode("utf-8", errors="replace") + else: + decoded = raw.decode("utf-8", errors="replace") + + if include_payload: + payloads_decoded.append({ + "payload_metadata_id": pid, + "flow_step_id": p_info.get("flow_step_id"), + "content_type": ctype, + "data": decoded, + }) + + # Look for a reference to an originating flow run ID in the payload + if isinstance(decoded, dict) and not originating_run_id: + for key in ("flow_run_id", "run_id", "original_run_id", + "source_run_id", "flowRunId"): + if decoded.get(key): + originating_run_id = str(decoded[key]) + break + # Also check nested structures (one level deep) + if not originating_run_id: + for val in decoded.values(): + if isinstance(val, dict): + for key in ("flow_run_id", "run_id", "original_run_id", + "source_run_id", "flowRunId"): + if val.get(key): + originating_run_id = str(val[key]) + break + if originating_run_id: + break + + # Stop downloading more payloads once we've found an originating run + if originating_run_id: + break + + except Exception as e: + if include_payload: + payloads_decoded.append({ + "payload_metadata_id": pid, + "error": str(e), + }) + + if include_payload and payloads_decoded: + result["payloads"] = payloads_decoded + + # If we found an originating run, summarise it — this is the REAL failure + if originating_run_id and originating_run_id != run_id: + result["originating_run_id"] = originating_run_id + result["originating_run_log_url"] = _run_log_url(originating_run_id) + + # Fetch the originating run's metadata to get its flow_id and flow_name + # so the model knows which flow to retry. + try: + orig_run_resp = get_flow_run(originating_run_id) + orig_run_data = orig_run_resp.get("data", {}) if isinstance(orig_run_resp, dict) else {} + orig_attrs = orig_run_data.get("attributes", {}) if isinstance(orig_run_data, dict) else {} + + # flow_id may be in attributes or relationships + orig_flow_id = orig_attrs.get("flow_id") + if not orig_flow_id: + rels = orig_run_data.get("relationships", {}) if isinstance(orig_run_data, dict) else {} + flow_rel = rels.get("flow", {}).get("data", {}) + if isinstance(flow_rel, dict): + orig_flow_id = flow_rel.get("id") + + if orig_flow_id: + result["originating_flow_id"] = orig_flow_id + + orig_flow_name = orig_attrs.get("flow_name") or orig_attrs.get("name") + if orig_flow_name: + result["originating_flow_name"] = orig_flow_name + + result["originating_run_started_at"] = orig_attrs.get("started_at") + result["originating_run_status"] = orig_attrs.get("status") + except Exception: + pass # Non-critical — we still have the run ID and log URL + + try: + orig_summary = summarise_failed_run(originating_run_id, max_logs=50) + result["originating_run_summary"] = orig_summary + + # Build an informative note with the originating flow ID for retrying + note_parts = [ + f"This is an alert/catch flow. The actual failure occurred in " + f"run {originating_run_id}." + ] + if result.get("originating_flow_id"): + note_parts.append( + f"To retry, use start_flow with flow_id='{result['originating_flow_id']}' " + f"(NOT the alert flow)." + ) + note_parts.append(f"See: {_run_log_url(originating_run_id)}") + result["note"] = " ".join(note_parts) + except Exception as e: + result["originating_run_summary_error"] = str(e) -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT + return result + + +def get_run_payloads( + run_id: str, + step_name: Optional[str] = None, +) -> Dict[str, Any]: + """ + Fetch all payloads for a given flow run in a single call. + Optionally filter by step_name (e.g. 'Catch', 'Try/Catch', 'Source Connector'). + Returns decoded payload content for each payload found in the run logs. + """ + logs_resp = get_flow_run_logs( + run_id=run_id, per_page=200, page=1, sort="id", + include="flowRunLogMetadata", fields_flowStep="id,name", + load_payload_ids=True, ) - return _handle(r) - -def get_customers(inputSchema: Optional[str] = None) -> Any: + items = logs_resp.get("data", []) if isinstance(logs_resp, dict) else [] + + payload_entries: List[Dict[str, Any]] = [] + seen_ids: set = set() + + for item in items: + attrs = item.get("attributes", {}) if isinstance(item, dict) else {} + pid = attrs.get("payload_metadata_id") + if not pid or pid in seen_ids: + continue + + # If step_name filter is set, check the flow step name + if step_name: + step_info = attrs.get("flow_step", {}) or {} + sname = step_info.get("name", "") or attrs.get("flow_step_name", "") + if step_name.lower() not in sname.lower(): + continue + + seen_ids.add(pid) + entry: Dict[str, Any] = { + "payload_metadata_id": pid, + "log_message": attrs.get("log_message") or attrs.get("message"), + "flow_step_id": attrs.get("flow_step_id"), + } + + try: + ctype, raw = download_payload(pid) + if "json" in ctype.lower(): + try: + entry["data"] = json.loads(raw) + except Exception: + entry["data"] = raw.decode("utf-8", errors="replace") + else: + entry["data"] = raw.decode("utf-8", errors="replace") + entry["content_type"] = ctype + except Exception as e: + entry["error"] = str(e) + + payload_entries.append(entry) + + return { + "run_id": run_id, + "step_filter": step_name, + "payload_count": len(payload_entries), + "payloads": payload_entries, + } + + +# ------------------------------------------------------------------------------ +# Commerce Foundation helpers +# ------------------------------------------------------------------------------ + +def _cf_post(callback_url: str, operation_name: str, inputSchema: Optional[str] = None) -> Any: + """Post to a Commerce Foundation callback URL.""" + if not callback_url: + raise RuntimeError( + f"No callback URL configured for '{operation_name}'. " + f"Set the corresponding PATCHWORKS_CALLBACK_* environment variable." + ) body = {} if inputSchema: body["inputSchema"] = inputSchema - -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) + r = session.post(callback_url, data=json.dumps(body), timeout=TIMEOUT) return _handle(r) - + +# ------------------------------------------------------------------------------ +# Commerce Foundation - Query Tools +# ------------------------------------------------------------------------------ + +def get_orders(inputSchema: Optional[str] = None) -> Any: + return _cf_post(CF_CALLBACK_ORDERS, "get_orders", inputSchema) + +def get_customers(inputSchema: Optional[str] = None) -> Any: + return _cf_post(CF_CALLBACK_CUSTOMERS, "get_customers", inputSchema) + def get_products(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema + return _cf_post(CF_CALLBACK_PRODUCTS, "get_products", inputSchema) -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) - def get_product_variants(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema - -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) + return _cf_post(CF_CALLBACK_PRODUCT_VARIANTS, "get_product_variants", inputSchema) def get_inventory(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema + return _cf_post(CF_CALLBACK_INVENTORY, "get_inventory", inputSchema) -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "https://callbacks.wearepatchworks.com/api/v1/jim_sandbox/01kae1cxrmdvphywp405v12pfn/2?patchworks_signature=f1pdveppf50prh2makkc5vhyr121wp010wyvcxd01qpp4av1xm4e", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) - def get_fulfillments(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema + return _cf_post(CF_CALLBACK_FULFILLMENTS, "get_fulfillments", inputSchema) -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) - def get_returns(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema - -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) + return _cf_post(CF_CALLBACK_RETURNS, "get_returns", inputSchema) # ------------------------------------------------------------------------------ -# Commerce Operations Foundation - Action Tools -# Configure in the callback flow URL for your specific account implementation +# Commerce Foundation - Action Tools # ------------------------------------------------------------------------------ -def create_sales_order(inputSchema) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema +def create_sales_order(inputSchema: Optional[str] = None) -> Any: + return _cf_post(CF_CALLBACK_CREATE_ORDER, "create_sales_order", inputSchema) -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) - def update_order(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema - -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) + return _cf_post(CF_CALLBACK_UPDATE_ORDER, "update_order", inputSchema) def cancel_order(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema - -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) + return _cf_post(CF_CALLBACK_CANCEL_ORDER, "cancel_order", inputSchema) def fulfill_order(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema + return _cf_post(CF_CALLBACK_FULFILL_ORDER, "fulfill_order", inputSchema) -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) - def create_return(inputSchema: Optional[str] = None) -> Any: - body = {} - if inputSchema: - body["inputSchema"] = inputSchema - -# Configure in the callback flow URL here in the quotes under session.post - r = session.post( - "", - data=json.dumps(body), - timeout=TIMEOUT - ) - return _handle(r) + return _cf_post(CF_CALLBACK_CREATE_RETURN, "create_return", inputSchema) diff --git a/providers/__init__.py b/providers/__init__.py new file mode 100644 index 0000000..ef99cf7 --- /dev/null +++ b/providers/__init__.py @@ -0,0 +1,46 @@ +"""LLM provider factory. + +Reads LLM_PROVIDER from the environment (default: "anthropic") and returns +the corresponding provider instance. Provider-specific API keys and model +overrides are also read from env vars — see .env.example. +""" +from __future__ import annotations + +import os +import logging + +from .base import LLMProvider + +log = logging.getLogger("patchworks-mcp") + +SUPPORTED_PROVIDERS = ("anthropic", "openai", "gemini") + + +def get_provider(name: str | None = None) -> LLMProvider: + """Return an LLMProvider instance for the given (or configured) provider name. + + Args: + name: One of "anthropic", "openai", "gemini". + Falls back to the LLM_PROVIDER env var, then to "anthropic". + """ + provider_name = (name or os.getenv("LLM_PROVIDER", "anthropic")).lower().strip() + + if provider_name == "anthropic": + from .anthropic_provider import AnthropicProvider + log.info("Using Anthropic (Claude) provider — model: %s", os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-20250514")) + return AnthropicProvider() + + if provider_name == "openai": + from .openai_provider import OpenAIProvider + log.info("Using OpenAI provider — model: %s", os.getenv("OPENAI_MODEL", "gpt-4o")) + return OpenAIProvider() + + if provider_name == "gemini": + from .gemini_provider import GeminiProvider + log.info("Using Gemini provider — model: %s", os.getenv("GEMINI_MODEL", "gemini-2.0-flash")) + return GeminiProvider() + + raise ValueError( + f"Unknown LLM_PROVIDER: {provider_name!r}. " + f"Supported providers: {', '.join(SUPPORTED_PROVIDERS)}" + ) diff --git a/providers/anthropic_provider.py b/providers/anthropic_provider.py new file mode 100644 index 0000000..5633c4a --- /dev/null +++ b/providers/anthropic_provider.py @@ -0,0 +1,122 @@ +"""Anthropic (Claude) provider — extracted from the original server.py /chat logic.""" +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, Callable + +import anthropic + +from .base import LLMProvider + +log = logging.getLogger("patchworks-mcp") + +DEFAULT_MODEL = "claude-sonnet-4-20250514" + + +class AnthropicProvider(LLMProvider): + """Runs the agentic chat loop against the Anthropic Messages API.""" + + def __init__(self): + self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) + self.model = os.getenv("ANTHROPIC_MODEL", DEFAULT_MODEL) + + # ------------------------------------------------------------------ + # Retry helper (exponential back-off on rate limits) + # ------------------------------------------------------------------ + async def _call_with_retry(self, **kwargs) -> anthropic.types.Message: + max_retries = 4 + base_delay = 15.0 + + for attempt in range(max_retries): + try: + return self.client.messages.create(**kwargs) + except anthropic.RateLimitError: + if attempt == max_retries - 1: + log.error("Rate limit exceeded after %d attempts", max_retries) + raise + delay = base_delay * (2 ** attempt) + jitter = delay * 0.1 * (0.5 + asyncio.get_event_loop().time() % 1) + total_delay = min(delay + jitter, 90) + log.warning( + "Rate limited. Waiting %.1fs before retry %d/%d", + total_delay, attempt + 2, max_retries, + ) + await asyncio.sleep(total_delay) + except Exception: + log.error("Claude API error", exc_info=True) + raise + + raise RuntimeError("Unexpected: retry loop completed without return") + + # ------------------------------------------------------------------ + # Agentic loop + # ------------------------------------------------------------------ + async def run_chat( + self, + messages: list[dict], + system_prompt: str, + tools: list[dict], + tool_executor: Callable[[str, dict], str], + max_iterations: int, + ) -> str: + # Tools are already in Anthropic format — use as-is. + for iteration in range(max_iterations): + log.info("Anthropic API call (iteration %d/%d)", iteration + 1, max_iterations) + + try: + response = await self._call_with_retry( + model=self.model, + max_tokens=2048, + system=[ + { + "type": "text", + "text": system_prompt, + "cache_control": {"type": "ephemeral"}, + } + ], + tools=tools, + messages=messages, + ) + except Exception as e: + return ( + "I encountered an error communicating with the AI service. " + f"Please try again. Error: {e}" + ) + + # Model finished + if response.stop_reason == "end_turn": + text_parts = [b.text for b in response.content if b.type == "text"] + return "\n".join(text_parts) or "No response generated." + + # Model wants to use tools + if response.stop_reason == "tool_use": + messages.append({ + "role": "assistant", + "content": [block.model_dump() for block in response.content], + }) + + tool_results = [] + for block in response.content: + if block.type == "tool_use": + log.info("Executing tool: %s", block.name) + result_str = tool_executor(block.name, block.input) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result_str, + }) + + messages.append({"role": "user", "content": tool_results}) + continue + + # Unexpected stop reason + log.warning("Unexpected stop_reason: %s", response.stop_reason) + text_parts = [b.text for b in response.content if b.type == "text"] + return "\n".join(text_parts) or "Unexpected response from assistant." + + return ( + f"I needed more than {max_iterations} tool calls to answer this. " + "Please try asking a more specific question or breaking it into smaller requests." + ) diff --git a/providers/base.py b/providers/base.py new file mode 100644 index 0000000..3a8be83 --- /dev/null +++ b/providers/base.py @@ -0,0 +1,39 @@ +"""Abstract base class for LLM providers used by the /chat endpoint.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable + + +class LLMProvider(ABC): + """Interface that each LLM provider must implement. + + The provider is responsible for: + - Converting tool definitions to its native format + - Running the agentic loop (API call → tool execution → repeat) + - Returning the final text response + """ + + @abstractmethod + async def run_chat( + self, + messages: list[dict], + system_prompt: str, + tools: list[dict], + tool_executor: Callable[[str, dict], str], + max_iterations: int, + ) -> str: + """Run the agentic chat loop. + + Args: + messages: Conversation history in a provider-neutral format. + Each message has {"role": "user"|"assistant", "content": str}. + system_prompt: System instructions for the model. + tools: Tool definitions in Anthropic format (canonical source). + tool_executor: Callable(tool_name, tool_input) -> result_str. + max_iterations: Max number of tool-use round-trips. + + Returns: + The final assistant text response. + """ + ... diff --git a/providers/gemini_provider.py b/providers/gemini_provider.py new file mode 100644 index 0000000..96bcea8 --- /dev/null +++ b/providers/gemini_provider.py @@ -0,0 +1,172 @@ +"""Google Gemini provider for the /chat endpoint.""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any, Callable + +from google import genai +from google.genai import types + +from .base import LLMProvider +from .tool_converter import to_gemini + +log = logging.getLogger("patchworks-mcp") + +DEFAULT_MODEL = "gemini-2.5-flash" + + +class GeminiProvider(LLMProvider): + """Runs the agentic chat loop against the Google Gemini API.""" + + def __init__(self): + self.client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY")) + self.model = os.getenv("GEMINI_MODEL", DEFAULT_MODEL) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @staticmethod + def _build_tools(anthropic_tools: list[dict]) -> list[types.Tool]: + """Convert Anthropic tool definitions to Gemini Tool objects.""" + declarations = to_gemini(anthropic_tools) + return [types.Tool(function_declarations=declarations)] + + @staticmethod + def _extract_text(response) -> str: + """Pull plain text from a Gemini GenerateContentResponse.""" + parts = [] + for candidate in response.candidates: + for part in candidate.content.parts: + if part.text: + parts.append(part.text) + return "\n".join(parts) + + @staticmethod + def _extract_function_calls(response) -> list[tuple[str, dict]]: + """Return [(name, args), ...] for all function_call parts in the response.""" + calls = [] + for candidate in response.candidates: + for part in candidate.content.parts: + if part.function_call: + fc = part.function_call + # fc.args is a proto Struct — convert to plain dict + args = dict(fc.args) if fc.args else {} + calls.append((fc.name, args)) + return calls + + # ------------------------------------------------------------------ + # Retry helper + # ------------------------------------------------------------------ + async def _call_with_retry(self, **kwargs): + max_retries = 4 + base_delay = 15.0 + + for attempt in range(max_retries): + try: + return await self.client.aio.models.generate_content(**kwargs) + except Exception as e: + error_str = str(e).lower() + if "resource exhausted" in error_str or "429" in error_str: + if attempt == max_retries - 1: + log.error("Gemini rate limit exceeded after %d attempts", max_retries) + raise + delay = base_delay * (2 ** attempt) + jitter = delay * 0.1 * (0.5 + asyncio.get_event_loop().time() % 1) + total_delay = min(delay + jitter, 90) + log.warning( + "Rate limited. Waiting %.1fs before retry %d/%d", + total_delay, attempt + 2, max_retries, + ) + await asyncio.sleep(total_delay) + else: + log.error("Gemini API error", exc_info=True) + raise + + raise RuntimeError("Unexpected: retry loop completed without return") + + # ------------------------------------------------------------------ + # Agentic loop + # ------------------------------------------------------------------ + async def run_chat( + self, + messages: list[dict], + system_prompt: str, + tools: list[dict], + tool_executor: Callable[[str, dict], str], + max_iterations: int, + ) -> str: + gemini_tools = self._build_tools(tools) + + # Build Gemini contents from message history. + # Gemini uses "user" and "model" roles (not "assistant"). + contents: list[types.Content] = [] + for msg in messages: + role = "model" if msg["role"] == "assistant" else "user" + contents.append( + types.Content( + role=role, + parts=[types.Part.from_text(text=msg["content"])], + ) + ) + + config = types.GenerateContentConfig( + system_instruction=system_prompt, + tools=gemini_tools, + max_output_tokens=2048, + ) + + for iteration in range(max_iterations): + log.info("Gemini API call (iteration %d/%d)", iteration + 1, max_iterations) + + try: + response = await self._call_with_retry( + model=self.model, + contents=contents, + config=config, + ) + except Exception as e: + return ( + "I encountered an error communicating with the AI service. " + f"Please try again. Error: {e}" + ) + + function_calls = self._extract_function_calls(response) + + # No function calls — model is done + if not function_calls: + return self._extract_text(response) or "No response generated." + + # Append the model's response (with function calls) to contents + contents.append(response.candidates[0].content) + + # Execute tools and build function responses + function_response_parts = [] + for name, args in function_calls: + log.info("Executing tool: %s", name) + result_str = tool_executor(name, args) + # Parse result back to dict for Gemini (it expects structured data) + try: + result_data = json.loads(result_str) + except json.JSONDecodeError: + result_data = {"result": result_str} + + function_response_parts.append( + types.Part.from_function_response( + name=name, + response=result_data, + ) + ) + + # Append tool results as a user turn + contents.append( + types.Content(role="user", parts=function_response_parts) + ) + continue + + return ( + f"I needed more than {max_iterations} tool calls to answer this. " + "Please try asking a more specific question or breaking it into smaller requests." + ) diff --git a/providers/openai_provider.py b/providers/openai_provider.py new file mode 100644 index 0000000..a0ced02 --- /dev/null +++ b/providers/openai_provider.py @@ -0,0 +1,140 @@ +"""OpenAI (ChatGPT) provider for the /chat endpoint.""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any, Callable + +from openai import OpenAI, RateLimitError + +from .base import LLMProvider +from .tool_converter import to_openai + +log = logging.getLogger("patchworks-mcp") + +DEFAULT_MODEL = "gpt-4o" + + +class OpenAIProvider(LLMProvider): + """Runs the agentic chat loop against the OpenAI Chat Completions API.""" + + def __init__(self): + self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + self.model = os.getenv("OPENAI_MODEL", DEFAULT_MODEL) + + # ------------------------------------------------------------------ + # Retry helper + # ------------------------------------------------------------------ + async def _call_with_retry(self, **kwargs): + max_retries = 4 + base_delay = 15.0 + + for attempt in range(max_retries): + try: + return self.client.chat.completions.create(**kwargs) + except RateLimitError: + if attempt == max_retries - 1: + log.error("OpenAI rate limit exceeded after %d attempts", max_retries) + raise + delay = base_delay * (2 ** attempt) + jitter = delay * 0.1 * (0.5 + asyncio.get_event_loop().time() % 1) + total_delay = min(delay + jitter, 90) + log.warning( + "Rate limited. Waiting %.1fs before retry %d/%d", + total_delay, attempt + 2, max_retries, + ) + await asyncio.sleep(total_delay) + except Exception: + log.error("OpenAI API error", exc_info=True) + raise + + raise RuntimeError("Unexpected: retry loop completed without return") + + # ------------------------------------------------------------------ + # Agentic loop + # ------------------------------------------------------------------ + async def run_chat( + self, + messages: list[dict], + system_prompt: str, + tools: list[dict], + tool_executor: Callable[[str, dict], str], + max_iterations: int, + ) -> str: + openai_tools = to_openai(tools) + + # Build OpenAI message history. + # OpenAI uses a system message in the messages array (not a separate param). + openai_messages: list[dict] = [{"role": "system", "content": system_prompt}] + + for msg in messages: + openai_messages.append({"role": msg["role"], "content": msg["content"]}) + + for iteration in range(max_iterations): + log.info("OpenAI API call (iteration %d/%d)", iteration + 1, max_iterations) + + try: + response = await self._call_with_retry( + model=self.model, + max_tokens=2048, + tools=openai_tools, + messages=openai_messages, + ) + except Exception as e: + return ( + "I encountered an error communicating with the AI service. " + f"Please try again. Error: {e}" + ) + + choice = response.choices[0] + message = choice.message + + # Model finished — no tool calls + if choice.finish_reason == "stop": + return message.content or "No response generated." + + # Model wants to use tools + if choice.finish_reason == "tool_calls" and message.tool_calls: + # Append the assistant message (with tool_calls) to history + openai_messages.append({ + "role": "assistant", + "content": message.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in message.tool_calls + ], + }) + + # Execute each tool and append results + for tc in message.tool_calls: + log.info("Executing tool: %s", tc.function.name) + try: + tool_input = json.loads(tc.function.arguments) + except json.JSONDecodeError: + tool_input = {} + result_str = tool_executor(tc.function.name, tool_input) + openai_messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result_str, + }) + + continue + + # Unexpected finish reason + log.warning("Unexpected finish_reason: %s", choice.finish_reason) + return message.content or "Unexpected response from assistant." + + return ( + f"I needed more than {max_iterations} tool calls to answer this. " + "Please try asking a more specific question or breaking it into smaller requests." + ) diff --git a/providers/tool_converter.py b/providers/tool_converter.py new file mode 100644 index 0000000..724326f --- /dev/null +++ b/providers/tool_converter.py @@ -0,0 +1,92 @@ +"""Convert tool definitions from Anthropic format to OpenAI and Gemini formats.""" +from __future__ import annotations + +import copy +from typing import Any + +# Keys that Gemini's OpenAPI-subset schema does not support. +_GEMINI_UNSUPPORTED_KEYS = {"additionalProperties", "anyOf"} + + +def _sanitize_for_gemini(schema: dict) -> dict: + """Recursively clean a JSON Schema for Gemini compatibility. + + Handles: + - anyOf-with-null → collapses to the non-null type + - additionalProperties → removed (Gemini rejects unknown fields) + """ + schema = copy.deepcopy(schema) + + # --- Strip additionalProperties at every level --- + schema.pop("additionalProperties", None) + + # --- Collapse anyOf-with-null --- + if "anyOf" in schema: + non_null = [s for s in schema["anyOf"] if s.get("type") != "null"] + if len(non_null) == 1: + # Merge the non-null type back, preserving description/default + merged = {k: v for k, v in schema.items() if k != "anyOf"} + merged.update(non_null[0]) + # The merged branch might itself contain unsupported keys + schema = _sanitize_for_gemini(merged) + return schema + elif len(non_null) > 1: + # Multiple non-null types — keep as-is (rare in this codebase) + pass + # Remove the anyOf key even if we couldn't collapse it + # (Gemini doesn't support anyOf at all) + if "anyOf" in schema: + schema.pop("anyOf") + if non_null: + schema.update(non_null[0]) + + # --- Recurse into properties --- + if "properties" in schema: + for key, prop in schema["properties"].items(): + schema["properties"][key] = _sanitize_for_gemini(prop) + + # --- Recurse into items --- + if "items" in schema and isinstance(schema["items"], dict): + schema["items"] = _sanitize_for_gemini(schema["items"]) + + return schema + + +def to_openai(anthropic_tools: list[dict]) -> list[dict]: + """Convert Anthropic tool definitions to OpenAI function-calling format. + + Anthropic: {"name", "description", "input_schema": {JSON Schema}} + OpenAI: {"type": "function", "function": {"name", "description", "parameters": {JSON Schema}}} + """ + openai_tools = [] + for tool in anthropic_tools: + openai_tools.append({ + "type": "function", + "function": { + "name": tool["name"], + "description": tool.get("description", ""), + "parameters": tool.get("input_schema", {}), + }, + }) + return openai_tools + + +def to_gemini(anthropic_tools: list[dict]) -> list[dict]: + """Convert Anthropic tool definitions to Gemini function-declaration format. + + Gemini uses a subset of OpenAPI Schema and does not support: + - anyOf (used for nullable types) + - additionalProperties + + Returns a list of function declarations (not wrapped in a Tool object — + the provider handles that). + """ + declarations = [] + for tool in anthropic_tools: + params = _sanitize_for_gemini(tool.get("input_schema", {})) + declarations.append({ + "name": tool["name"], + "description": tool.get("description", ""), + "parameters": params, + }) + return declarations diff --git a/pyproject.toml b/pyproject.toml index 7bb1132..a1cd9a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,5 +6,8 @@ dependencies = [ "mcp[cli]>=0.1.0", "pydantic>=2.7", "python-dotenv>=1.0", - "requests>=2.32" + "requests>=2.32", + "anthropic>=0.42.0", + "openai>=1.0", + "google-genai>=1.0", ] \ No newline at end of file diff --git a/server.py b/server.py index aa02021..3d6e0b1 100644 --- a/server.py +++ b/server.py @@ -2,11 +2,26 @@ import os, json, sys, logging, base64 from typing import Any, List, Optional, Dict from typing_extensions import Annotated +from pathlib import Path from pydantic import BaseModel, Field, ConfigDict from mcp.server.fastmcp import FastMCP import re +import asyncio + +from dotenv import load_dotenv +load_dotenv(dotenv_path=Path(__file__).parent / ".env", override=True) + +import uvicorn +import socket +import multiprocessing +from contextlib import asynccontextmanager +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route, Mount import patchworks_client as pw +from docs_search import get_index as _get_docs_index from datetime import datetime # Log to STDERR only (stdio transport cannot receive stdout noise) @@ -17,12 +32,8 @@ # Regex patterns derived from your JSON Schema -# Note: JSON double backslashes (\\) are converted to single backslashes (\) for Python raw strings. DATE_PATTERN = r"^(?:(?:(?:(?:(?:[13579][26]|[2468][048])00)|(?:[0-9]{2}(?:(?:[13579][26])|(?:[2468][048]|0[48]))))(?:-)(?:02)(?:-)(?:29))|(?:(?:[0-9]{4})(?:-)(?:(?:(?:0[13578]|1[02])(?:-)(?:31))|(?:(?:0[1,3-9]|1[0-2])(?:-)(?:29|30))|(?:(?:0[1-9])|(?:1[0-2]))(?:-)(?:0[1-9]|1[0-9]|2[0-8]))))(?:T)(?:[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?)(?:Z)$" -# The provided regex in the prompt was slightly complex; this is the cleaned raw string version of the ISO8601 pattern provided. -# If you prefer the exact raw pattern from the prompt: PROMPT_DATE_PATTERN = r"^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$" - EMAIL_PATTERN = r"^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$" # ------------------------------------------------------------------------------ @@ -37,6 +48,7 @@ class GetAllFlowsArgs(BaseModel): class GetFlowRunsArgs(BaseModel): status: Optional[int] = Field(None, description="1=STARTED, 2=SUCCESS, 3=FAILURE, 4=STOPPED, 5=PARTIAL_SUCCESS") started_after: Optional[str] = Field(None, description="Timestamp or epoch-ms as string") + flow_id: Optional[int] = Field(None, description="Filter runs by flow ID") page: int = Field(1, ge=1) per_page: int = Field(50, ge=1, le=200) sort: Optional[str] = Field("-started_at") @@ -74,7 +86,7 @@ class ListDataPoolsArgs(BaseModel): class GetDedupedDataArgs(BaseModel): pool_id: str page: int = Field(1, ge=1) - per_page: int = Field(50, ge=1, le=200) + per_page: int = Field(50, ge=1, le=200) class CreateProcessFlowByPromptArgs(BaseModel): prompt: str = Field(..., description="e.g. 'create a process flow for Shopify to NetSuite orders'") @@ -85,6 +97,17 @@ class CreateProcessFlowByPromptArgs(BaseModel): class CreateProcessFlowFromJsonArgs(BaseModel): body: Dict[str, Any] = Field(..., description="Complete /flows/import JSON to send as-is") +class InvestigateFailureArgs(BaseModel): + flow_id: Optional[int] = Field(None, description="Numeric flow ID (if known)") + flow_name: Optional[str] = Field(None, description="Flow name to search for (case-insensitive)") + run_id: Optional[str] = Field(None, description="Specific flow run ID to investigate") + include_payload: bool = Field(False, description="Include full payload content in the response. Alert chain following always happens regardless.") + failed_at: Optional[str] = Field(None, description="Timestamp of the failure from the alert message (e.g. '2026-02-24 13:34:04'). When provided, matches the run closest to this timestamp instead of using the most recent run.") + +class GetRunPayloadsArgs(BaseModel): + run_id: str = Field(..., description="Flow run ID to fetch payloads for") + step_name: Optional[str] = Field(None, description="Filter payloads by step name (e.g. 'Catch', 'Source Connector')") + ENTITY_GUESSES = [ "orders","order","customers","customer","products","inventory","shipments","invoices","payments" @@ -92,7 +115,8 @@ class CreateProcessFlowFromJsonArgs(BaseModel): def _guess_parts_from_prompt(prompt: str) -> Dict[str, str]: p = prompt.strip().lower() - m = re.search(r"for\s+(.+?)\s+to\s+(.+?)\s+([a-zA-Z\-_/ ]+)$", p) or re.search(r"(?:create|build).*\bflow\b.*?for\s+(.+?)\s+to\s+(.+?)\s+([a-zA-Z\-_/ ]+)$", p) + m = re.search(r"for\s+(.+?)\s+to\s+(.+?)\s+([a-zA-Z\-_/ ]+)$", p) or \ + re.search(r"(?:create|build).*\bflow\b.*?for\s+(.+?)\s+to\s+(.+?)\s+([a-zA-Z\-_/ ]+)$", p) src = dst = ent = None if m: src, dst, ent = [x.strip() for x in m.groups()] @@ -192,1004 +216,591 @@ def _build_generic_import_json(src: str, dst: str, entity: str, *, priority: int "name": src, "label": f"{src} (placeholder)", "protocol": "HTTP" - } + }, + "connector": { + "name": f"{src} Connector", + "timezone": "Europe/London" + }, + "endpoints": [ + { + "name": f"{src} - Retrieve {entity}", + "endpoint": "https://placeholder.example.com/api", + "http_method": "GET", + "data_type": "json", + "direction": "Receive" + } + ] }, { "system": { "name": dst, "label": f"{dst} (placeholder)", "protocol": "HTTP" - } + }, + "connector": { + "name": f"{dst} Connector", + "timezone": "Europe/London" + }, + "endpoints": [ + { + "name": f"{dst} - Upsert {entity}", + "endpoint": "https://placeholder.example.com/api", + "http_method": "POST", + "data_type": "json", + "direction": "Send" + } + ] } ] return { - "metadata": { - "company_name": "Generated by MCP", - "flow_name": flow_name, - "exported_at": now - }, "flow": flow, "systems": systems - } + } -# ------------------------------------------------------------------------------ -# Patchworks Tools -# ------------------------------------------------------------------------------ -@mcp.tool() -def get_all_flows(args: GetAllFlowsArgs) -> Any: - """List flows from the Core API.""" - return pw.get_all_flows(page=args.page, per_page=args.per_page, include=args.include) +# --- Tool Registration (MCP) --- +# These are only used when running as an MCP server, not via /chat @mcp.tool() -def get_flow_runs(args: GetFlowRunsArgs) -> Any: - """Query flow runs (filter by status, started_after; sort; includes).""" - return pw.get_flow_runs( - status=args.status, - started_after=args.started_after, - page=args.page, - per_page=args.per_page, - sort=args.sort, - include=args.include, - ) +def get_all_flows(args: GetAllFlowsArgs) -> Dict[str, Any]: + """Retrieve all flows (integrations) visible to this user.""" + return pw.get_all_flows(**args.model_dump()) @mcp.tool() -def get_flow_run_logs(args: GetFlowRunLogsArgs) -> Any: - """Retrieve logs for a specific flow run (optionally with payload IDs).""" - return pw.get_flow_run_logs( - run_id=args.run_id, - per_page=args.per_page, - page=args.page, - sort=args.sort, - include=args.include, - fields_flowStep=args.fields_flowStep, - load_payload_ids=args.load_payload_ids, - ) +def get_flow_runs(args: GetFlowRunsArgs) -> Dict[str, Any]: + """Get flow runs with optional filtering by status, date range, etc.""" + return pw.get_flow_runs(**args.model_dump()) +@mcp.tool() +def get_flow_run_logs(args: GetFlowRunLogsArgs) -> Dict[str, Any]: + """Fetch logs for a specific flow run to debug failures or see details.""" + return pw.get_flow_run_logs(**args.model_dump()) @mcp.tool() -def summarise_failed_run(args: SummariseFailedRunArgs) -> Any: - """Summarise what went wrong in a failed run by inspecting log levels/messages.""" - return pw.summarise_failed_run(run_id=args.run_id, max_logs=args.max_logs) +def summarise_failed_run(args: SummariseFailedRunArgs) -> Dict[str, Any]: + """Analyse a failed run and produce a concise summary of what went wrong.""" + return pw.summarise_failed_run(**args.model_dump()) @mcp.tool() -def triage_latest_failures(args: TriageLatestFailuresArgs) -> Any: - """Fetch recent failed runs and return a compact summary for each.""" - return pw.triage_latest_failures( - started_after=args.started_after, - limit=args.limit, - per_run_log_limit=args.per_run_log_limit, - ) +def triage_latest_failures(args: TriageLatestFailuresArgs) -> Dict[str, Any]: + """ + Triage multiple recent failures at once. Returns a summarised breakdown + of what failed, when, and why. Good for quickly assessing platform health. + """ + return pw.triage_latest_failures(**args.model_dump()) @mcp.tool() -def download_payload(args: DownloadPayloadArgs) -> Any: - """Download payload bytes for a given payload metadata ID (returned as base64).""" +def download_payload(args: DownloadPayloadArgs) -> Dict[str, str]: + """ + Download a payload from a flow run. Returns content_type and base64-encoded bytes. + """ ctype, raw = pw.download_payload(args.payload_metadata_id) - return {"content_type": ctype, "bytes_base64": base64.b64encode(raw).decode("ascii")} + return { + "content_type": ctype, + "bytes_base64": base64.b64encode(raw).decode("ascii") + } @mcp.tool() -def start_flow(args: StartFlowArgs) -> Any: - """Trigger a flow run via the Start service (/flows/{id}/start).""" - return pw.start_flow(flow_id=args.flow_id, payload=args.payload) +def start_flow(args: StartFlowArgs) -> Dict[str, Any]: + """Manually trigger a flow run. Optionally pass a JSON payload to inject data.""" + return pw.start_flow(**args.model_dump()) @mcp.tool() -def list_data_pools(args: ListDataPoolsArgs) -> Any: - """List all data/dedupe pools.""" - return pw.list_data_pools(page=args.page, per_page=args.per_page) +def list_data_pools(args: ListDataPoolsArgs) -> Dict[str, Any]: + """List all data pools (deduplicated data storage) in the platform.""" + return pw.list_data_pools(**args.model_dump()) @mcp.tool() -def get_deduped_data(args: GetDedupedDataArgs) -> Any: - """Retrieve deduplicated data for a specific pool.""" - return pw.get_deduped_data(pool_id=args.pool_id, page=args.page, per_page=args.per_page) +def get_deduped_data(args: GetDedupedDataArgs) -> Dict[str, Any]: + """Retrieve deduped records from a specific pool.""" + return pw.get_deduped_data(**args.model_dump()) @mcp.tool() -def create_process_flow_from_prompt(args: CreateProcessFlowByPromptArgs) -> Any: +def create_process_flow_from_prompt(args: CreateProcessFlowByPromptArgs) -> Dict[str, Any]: """ - Build a generic flow from a natural-language prompt and import it. - Produces a Try/Catch → Source Connector → Batch → Map → Destination Connector skeleton. + Create a new flow from a natural language prompt. e.g.: + "create a process flow for Shopify to NetSuite orders" """ parts = _guess_parts_from_prompt(args.prompt) body = _build_generic_import_json( parts["source"], parts["destination"], parts["entity"], priority=args.priority, schedule_cron=args.schedule_cron, - enable=args.enable + enable=args.enable, ) return pw.import_flow(body) @mcp.tool() -def create_process_flow_from_json(args: CreateProcessFlowFromJsonArgs) -> Any: +def create_process_flow_from_json(args: CreateProcessFlowFromJsonArgs) -> Dict[str, Any]: """ - Import a flow with the exact JSON body provided. - Useful when you want to post a full export unchanged. + Import a flow from a full JSON structure (as exported from /flows/export). + Allows advanced users to craft custom flow definitions. """ return pw.import_flow(args.body) -# ------------------------------------------------------------------------------ -# Commerce Foundation Operation Query Tools -# ------------------------------------------------------------------------------ - -# ------------------------------------------------------------------------------ -# Tool get-customers Commerce Foundation Operation Query Tools -# ------------------------------------------------------------------------------ -class GetCustomersArgs(BaseModel): - # Enforce "additionalProperties": false - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - - updatedAtMin: Optional[str] = Field( - default=None, - description="Minimum updated at date (inclusive)", - pattern=PROMPT_DATE_PATTERN, - json_schema_extra={"format": "date-time"} - ) - - updatedAtMax: Optional[str] = Field( - default=None, - description="Maximum updated at date (inclusive)", - pattern=PROMPT_DATE_PATTERN, - json_schema_extra={"format": "date-time"} - ) - - createdAtMin: Optional[str] = Field( - default=None, - description="Minimum created at date (inclusive)", - pattern=PROMPT_DATE_PATTERN, - json_schema_extra={"format": "date-time"} - ) - - createdAtMax: Optional[str] = Field( - default=None, - description="Maximum created at date (inclusive)", - pattern=PROMPT_DATE_PATTERN, - json_schema_extra={"format": "date-time"} - ) - - pageSize: Optional[int] = Field( - default=10, - description="Number of results to return per page. Use with skip to paginate through results.", - gt=0, # exclusiveMinimum: 0 - le=9007199254740991 # maximum safe integer - ) - - skip: Optional[int] = Field( - default=0, - description="Number of results to skip. To navigate to the next page, increment skip by pageSize (e.g., skip=0 for first page, skip=100 for second page when pageSize=100).", - ge=0, # minimum: 0 - le=9007199254740991 # maximum safe integer - ) - - ids: Optional[List[str]] = Field( - default=None, - description="Unique customer ID in the Fulfillment System" - ) - - # We use Annotated to apply the regex pattern to the items *inside* the list - emails: Optional[List[Annotated[str, Field(pattern=EMAIL_PATTERN, json_schema_extra={"format": "email"})]]] = Field( - default=None, - description="Customer email address" - ) - -@mcp.tool() -def get_customers(args: Optional[GetCustomersArgs] = None) -> Any: - """Get customers as per JSON args in the input schema. If no args is provided, get all customers.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_customers(inputSchema=json_string_payload) +@mcp.tool() +def investigate_failure(args: InvestigateFailureArgs) -> Dict[str, Any]: + """ + All-in-one failure investigation. Provide a flow name, flow ID, or run ID. + Resolves the flow, finds the most recent failed run, summarises it, downloads + payloads (including catch-route payloads), and follows the alert chain to the + originating failed run if this is an alert/notification flow. + Returns a complete diagnostic in a single tool call. + """ + return pw.investigate_failure(**args.model_dump()) -# ------------------------------------------------------------------------------ -# Tool get-products Commerce Foundation Operation Query Tools -# ------------------------------------------------------------------------------ +@mcp.tool() +def get_run_payloads(args: GetRunPayloadsArgs) -> Dict[str, Any]: + """ + Retrieve all payloads for a flow run in a single call. + Optionally filter by step name (e.g. 'Catch', 'Source Connector'). + Downloads and decodes each payload, returning the content inline. + """ + return pw.get_run_payloads(**args.model_dump()) -class GetProductsArgs(BaseModel): - # Allows additionalProperties: false - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - ids: Optional[List[str]] = Field( - default=None, - description="Unique product ID in the Fulfillment System" - ) - skus: Optional[List[str]] = Field( - default=None, - description="Product SKU (Stock Keeping Unit)" - ) - updatedAtMin: Optional[str] = Field( - default=None, - description="Minimum updated at date (inclusive)", - pattern=DATE_PATTERN - ) - updatedAtMax: Optional[str] = Field( - default=None, - description="Maximum updated at date (inclusive)", - pattern=DATE_PATTERN - ) - createdAtMin: Optional[str] = Field( - default=None, - description="Minimum created at date (inclusive)", - pattern=DATE_PATTERN - ) - createdAtMax: Optional[str] = Field( - default=None, - description="Maximum created at date (inclusive)", - pattern=DATE_PATTERN - ) - pageSize: int = Field( - default=10, - gt=0, # exclusiveMinimum: 0 - le=9007199254740991, # maximum - description="Number of results to return per page. Use with skip to paginate through results." - ) - skip: int = Field( - default=0, - ge=0, # minimum: 0 - le=9007199254740991, # maximum - description="Number of results to skip. To navigate to the next page, increment skip by pageSize (e.g., skip=0 for first page, skip=100 for second page when pageSize=100)." - ) -@mcp.tool() -def get_products(args: Optional[GetProductsArgs] = None) -> Any: - """Get products as per JSON args in the input schema. If no args is provided, get all products.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_products(inputSchema=json_string_payload) # ------------------------------------------------------------------------------ -# Tool get-product-variants Commerce Foundation Operation Query Tools +# Documentation Knowledge Base # ------------------------------------------------------------------------------ -class GetProductVariantsArgs(BaseModel): - # Allows additionalProperties: false - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - - ids: Optional[List[str]] = Field( - default=None, - description="Unique variant IDs in the fulfillment system" - ) - - skus: Optional[List[str]] = Field( - default=None, - description="Variant SKUs (Stock Keeping Units)" - ) - - productIds: Optional[List[str]] = Field( - default=None, - description="Parent product IDs; returns all variants" - ) - - updatedAtMin: Optional[str] = Field( - default=None, - description="Minimum updated at date (inclusive)", - pattern=DATE_PATTERN - ) - - updatedAtMax: Optional[str] = Field( - default=None, - description="Maximum updated at date (inclusive)", - pattern=DATE_PATTERN - ) - - createdAtMin: Optional[str] = Field( - default=None, - description="Minimum created at date (inclusive)", - pattern=DATE_PATTERN - ) - - createdAtMax: Optional[str] = Field( - default=None, - description="Maximum created at date (inclusive)", - pattern=DATE_PATTERN - ) - - pageSize: int = Field( - default=10, - gt=0, # exclusiveMinimum: 0 - le=9007199254740991, # maximum - description="Number of results to return per page. Use with skip to paginate through results." - ) - - skip: int = Field( - default=0, - ge=0, # minimum: 0 - le=9007199254740991, # maximum - description="Number of results to skip. To navigate to the next page, increment skip by pageSize (e.g., skip=0 for first page, skip=100 for second page when pageSize=100)." - ) - - -@mcp.tool() -def get_product_variants(args: GetProductVariantsArgs) -> Any: - """Get product variants as per JSON args in the input schema. If no args is provided, get all variants.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_product_variants(inputSchema=json_string_payload) +@mcp.tool() +def search_docs(query: str, max_results: int = 3) -> Any: + """Search the Patchworks product documentation knowledge base. -# ------------------------------------------------------------------------------ -# Tool get-inventory Commerce Foundation Operation Query Tools -# ------------------------------------------------------------------------------ + Use this to answer questions about Patchworks concepts, features, and configuration + that are not specific to a particular account's data. Covers: getting started, + registration, subscription tiers, company setup, users & roles, marketplace, + blueprints, connectors & instances, process flows, virtual environments, + general settings, connector builder, custom scripting, the Patchworks API, + the Patchworks MCP server, and Stockr. + """ + idx = _get_docs_index() + results = idx.search(query, max_results=max_results) + if not results: + return {"results": [], "message": "No matching documentation found. Try different keywords."} + return {"results": results} -class GetInventoryArgs(BaseModel): - skus: Optional[List[str]] = Field( - default=None, - description="Product SKU to get inventory for (optional - if not provided returns all skus, filter by location if provided" - ) - locationIds: Optional[List[str]] = Field( - None, - description="Specific warehouse/location ID (optional - if not provided, returns all skus from all locations, unless sku filter is provided)" - ) +# Commerce Foundation Tools @mcp.tool() -def get_inventory(args: Optional[GetInventoryArgs] = None) -> Any: - """Input schema for querying inventory. Returns inventory data for specific SKUs.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_inventory(inputSchema=json_string_payload) +def get_customers(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query customers using the Commerce Foundation.""" + return pw.get_customers(inputSchema=inputSchema) +@mcp.tool() +def get_products(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query products using the Commerce Foundation.""" + return pw.get_products(inputSchema=inputSchema) -# ------------------------------------------------------------------------------ -# Tool get-returns Commerce Foundation Operation Query Tools -# ------------------------------------------------------------------------------ - -class GetReturnsArgs(BaseModel): - ids: Optional[List[str]] = Field( - default=None, - description="Internal return IDs" - ) - orderIds: Optional[List[str]] = Field( - default=None, - description="Order IDs to find returns for" - ) - returnNumbers: Optional[List[str]] = Field( - default=None, - description="Return numbers (customer-facing identifiers)" - ) - statuses: Optional[List[str]] = Field( - default=None, - description="Return statuses" - ) - outcomes: Optional[List[str]] = Field( - default=None, - description="Return outcomes (refund/exchange)" - ) - @mcp.tool() -def get_returns(args: Optional[GetReturnsArgs] = None) -> Any: - """Get inventory as per JSON args in the input schema. If no args is provided, get all inventory.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_returns(inputSchema=json_string_payload) +def get_product_variants(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query product variants (SKUs) using the Commerce Foundation.""" + return pw.get_product_variants(inputSchema=inputSchema) -# ------------------------------------------------------------------------------ -# Tool get-fulfillments Commerce Foundation Operation Query Tools -# ------------------------------------------------------------------------------ +@mcp.tool() +def get_inventory(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query inventory levels using the Commerce Foundation.""" + return pw.get_inventory(inputSchema=inputSchema) -class GetFulfillmentArgs(BaseModel): - model_config = ConfigDict(regex_engine='python-re') - ids: Optional[List[str]] = Field( - default=None, - description="Unique shipment ID in the Fulfillment System" - ) - orderIds: Optional[List[str]] = Field( - default=None, - description="Order ID associated with the shipment" - ) - updatedAtMin: Optional[str] = Field( - default=None, - description="Minimum updated at date (inclusive)", - pattern=DATE_PATTERN - ) - updatedAtMax: Optional[str] = Field( - default=None, - description="Maximum updated at date (inclusive)", - pattern=DATE_PATTERN - ) - createdAtMin: Optional[str] = Field( - default=None, - description="Minimum created at date (inclusive)", - pattern=DATE_PATTERN - ) - createdAtMax: Optional[str] = Field( - default=None, - description="Maximum created at date (inclusive)", - pattern=DATE_PATTERN - ) - pageSize: int = Field( - default=10, - description="Number of results to return per page. Use with skip to paginate through results.", - gt=0, # Exclusive minimum 0 - le=9007199254740991 # Maximum safety limit - ) - skip: int = Field( - default=0, - description="Number of results to skip. To navigate to the next page, increment skip by pageSize (e.g., skip=0 for first page, skip=100 for second page when pageSize=100).", - ge=0, # Minimum 0 - le=9007199254740991 - ) +@mcp.tool() +def get_returns(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query returns/refunds using the Commerce Foundation.""" + return pw.get_returns(inputSchema=inputSchema) - # This enforces "additionalProperties": false - model_config = ConfigDict(extra="forbid") +@mcp.tool() +def get_fulfillments(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query fulfillments/shipments using the Commerce Foundation.""" + return pw.get_fulfillments(inputSchema=inputSchema) @mcp.tool() -def get_fulfillments(args: Optional[GetFulfillmentArgs] = None) -> Any: - """Get inventory as per JSON args in the input schema. If no args is provided, get all inventory.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_fulfillments(inputSchema=json_string_payload) - -class GetOrdersArgs(BaseModel): - ids: Optional[List[str]] = Field( - None, - description="Internal order ID, could be a comma separated list" - ) - externalIds: Optional[List[str]] = Field( - None, - description="External order ID from source system, could be a comma separated list" - ) - statuses: Optional[List[str]] = Field( - None, - description="Order status" - ) - names: Optional[List[str]] = Field( - None, - description="Friendly Order identifier" - ) - includeLineItems: bool = Field( - True, - description="Whether to include detailed line item information in the returned orders" - ) - updatedAtMin: Optional[str] = Field( - None, - description="Minimum updated at date (inclusive). Format: ISO 8601 (YYYY-MM-DDThh:mm:ssZ)" - ) - updatedAtMax: Optional[str] = Field( - None, - description="Maximum updated at date (inclusive). Format: ISO 8601 (YYYY-MM-DDThh:mm:ssZ)" - ) - createdAtMin: Optional[str] = Field( - None, - description="Minimum created at date (inclusive). Format: ISO 8601 (YYYY-MM-DDThh:mm:ssZ)" - ) - createdAtMax: Optional[str] = Field( - None, - description="Maximum created at date (inclusive). Format: ISO 8601 (YYYY-MM-DDThh:mm:ssZ)" - ) - pageSize: int = Field( - 10, - description="Number of results to return per page. Use with skip to paginate through results.", - ge=0, - le=9007199254740991 - ) - skip: int = Field( - 0, - description="Number of results to skip. To navigate to the next page, increment skip by pageSize.", - ge=0, - le=9007199254740991 - ) +def get_orders(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Query orders using the Commerce Foundation.""" + return pw.get_orders(inputSchema=inputSchema) @mcp.tool() -def get_orders(args: Optional[GetOrdersArgs] = None) -> Any: - """Get orders as per JSON args in the input schema. If no args is provided, get all orders.""" - - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.get_orders(inputSchema=json_string_payload) +def create_sales_order(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Create a new sales order via the Commerce Foundation.""" + return pw.create_sales_order(inputSchema=inputSchema) +@mcp.tool() +def update_order(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Update an existing order via the Commerce Foundation.""" + return pw.update_order(inputSchema=inputSchema) -# ------------------------------------------------------------------------------ -# Commerce Foundation Operation Action Tools -# ------------------------------------------------------------------------------ +@mcp.tool() +def cancel_order(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Cancel an order via the Commerce Foundation.""" + return pw.cancel_order(inputSchema=inputSchema) -# ----------------------------------------------------------------------------- -# Shared / Sub-Models -# ----------------------------------------------------------------------------- - -class CustomField(BaseModel): - name: str - value: str - model_config = ConfigDict(regex_engine='python-re') - model_config = ConfigDict(extra='forbid') - -class Address(BaseModel): - model_config = ConfigDict(regex_engine='python-re') - address1: Optional[str] = Field(None, description='Primary street address (e.g., "123 Main Street")') - address2: Optional[str] = Field(None, description='Secondary address information such as apartment, suite, or unit number (e.g., "Apt 4B")') - city: Optional[str] = Field(None, description="City or town name") - company: Optional[str] = Field(None, description="Company or organization name associated with this address") - country: Optional[str] = Field(None, description='Country code in ISO 3166-1 alpha-2 format (2 letters, e.g., "US", "CA", "GB")') - email: Optional[str] = Field(Field(pattern=EMAIL_PATTERN, json_schema_extra={"format": "email"})) - firstName: Optional[str] = Field(None, description="First name of the person at this address") - lastName: Optional[str] = Field(None, description="Last name of the person at this address") - phone: Optional[str] = Field(None, description='Phone number including country code if applicable (e.g., "+1-555-123-4567")') - stateOrProvince: Optional[str] = Field(None, description='State or province. For US addresses, use 2-letter state code (e.g., "CA", "NY"). For other countries, use full province name or local standard.') - zipCodeOrPostalCode: Optional[str] = Field(None, description="ZIP code (US) or postal code (international) for the address") - - model_config = ConfigDict(extra='forbid') - -class CustomerAddressEntry(BaseModel): - """Wrapper for addresses inside the Customer object""" - name: Optional[str] = Field(None, description="Description of the address e.g. home, work, billing, shipping, etc") - address: Address - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class Customer(BaseModel): - id: str = Field(..., description="Unique system-generated identifier for this entity (read-only)") - externalId: Optional[str] = Field(None, description="ID of the entity in the client's system. Must be unique within the tenant.") - createdAt: str = Field( - ..., - description="ISO 8601 timestamp when the entity was created (read-only)", - pattern=r"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - ) - updatedAt: str = Field( - ..., - description="ISO 8601 timestamp when the entity was last updated (read-only)", - pattern=r"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - ) - tenantId: str = Field(..., description="Unique identifier for the tenant that owns this entity (read-only)") - addresses: Optional[List[CustomerAddressEntry]] = Field(None, description="List of addresses associated with the customer (e.g., shipping, billing, home, work)") - email: Optional[str] = Field(Field(pattern=EMAIL_PATTERN, json_schema_extra={"format": "email"})) - firstName: Optional[str] = Field(None, description="Customer's first name") - lastName: Optional[str] = Field(None, description="Customer's last name") - notes: Optional[str] = Field(None, description="Internal notes about the customer for reference (not visible to the customer)") - phone: Optional[str] = Field(None, description='Primary phone number including country code if applicable (e.g., "+1-555-123-4567")') - status: Optional[str] = Field(None, description='Customer account status (e.g., "active", "inactive", "suspended")') - type: Optional[str] = Field(None, description='Customer type (e.g., "individual" for personal customers or "company" for business customers)') - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields - allows for arbitrary key-value pairs to be added to an entity.") - tags: Optional[List[str]] = Field(None, description='Tags for categorization and filtering.') - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class LineItem(BaseModel): - id: Optional[str] = Field(None, description="Unique identifier for this line item within the order") - sku: str = Field(..., min_length=1, description="Product Variant SKU") - quantity: float = Field(..., minimum=1, description="Quantity ordered") - unitPrice: Optional[float] = Field(None, minimum=0, description="Price per unit") - unitDiscount: Optional[float] = Field(None, minimum=0, description="Discount per unit") - totalPrice: Optional[float] = Field(None, minimum=0, description="Total price for the line item. Calculated as (unitPrice - unitDiscount) * quantity") - name: Optional[str] = Field(None, description="Product name for display") - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class Order(BaseModel): - externalId: Optional[str] = Field(None, description="ID of the entity in the client's system. Must be unique within the tenant.") - name: Optional[str] = Field(None, description="Order name") - status: Optional[str] = Field(None, description="Order status") - billingAddress: Optional[Address] = Field(None, description="Billing address") - currency: Optional[str] = Field(None, description="Order currency code") - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - customer: Optional[Customer] = Field(None, description="Order customer information") - discounts: Optional[List[Dict[str, Any]]] = Field(None, description="Discounts") - lineItems: List[LineItem] - orderDiscount: Optional[float] = Field(None, description="Order Discount") - orderNote: Optional[str] = Field(None, description="Order Notes") - orderSource: Optional[str] = Field(None, description="The original order platform, walmart, etsy, etc") - orderTax: Optional[float] = Field(None, description="Order Tax") - paymentStatus: Optional[str] = Field(None, description="status of the payment") - payments: Optional[List[Dict[str, Any]]] = Field(None, description="Payments") - refunds: Optional[List[Dict[str, Any]]] = Field(None, description="Refunds") - subTotalPrice: Optional[float] = Field(None, description="Sub Total Price") - tags: Optional[List[str]] = Field(None, description='Tags for categorization and filtering.') - totalPrice: Optional[float] = Field(None, description="Total Price") - shippingAddress: Optional[Address] = Field(None, description="Shipping address") - shippingCarrier: Optional[str] = Field(None, description="Shipping carrier name eg. UPS, FedEx, USPS") - shippingClass: Optional[str] = Field(None, description="Service level e.g. Next Day, Express, Ground") - shippingCode: Optional[str] = None - shippingNote: Optional[str] = Field(None, description="Additional shipping notes") - shippingPrice: Optional[float] = Field(None, description="Shipping cost") - giftNote: Optional[str] = None - incoterms: Optional[str] = None - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -# ----------------------------------------------------------------------------- -# Commerce Operations Foundation - Create Sales Order -# ----------------------------------------------------------------------------- - -class CreateSalesOrderArgs(BaseModel): - """ - Input schema for creating a sales order. - Matches the schema title 'create-sales-order'. - """ - order: Order - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') +@mcp.tool() +def fulfill_order(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Mark an order as fulfilled via the Commerce Foundation.""" + return pw.fulfill_order(inputSchema=inputSchema) @mcp.tool() -def create_sales_order(args: CreateSalesOrderArgs) -> Any: - """Create Sales Order as per JSON args in the input schema https://raw.githubusercontent.com/commerce-operations-foundation/mcp-reference-server/refs/heads/develop/schemas/tool-inputs/create-sales-order.json. If no args is provided, error""" - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.create_sales_order(inputSchema=json_string_payload) +def create_return(inputSchema: Optional[str] = None) -> Dict[str, Any]: + """Create a return/refund via the Commerce Foundation.""" + return pw.create_return(inputSchema=inputSchema) -# ----------------------------------------------------------------------------- -# Commerce Operations Foundation - Update Sales Order -# ----------------------------------------------------------------------------- +# ------------------------------------------------------------------------------ +# /chat endpoint — Claude API with agentic tool use +# ------------------------------------------------------------------------------ +SYSTEM_PROMPT = """You are a Patchworks integration platform assistant. +You help users monitor and manage their integration flows, investigate failures, +check order/product/inventory data, and perform operations. + +**Formatting rules:** +- Use standard Markdown for all responses (headings, **bold**, `code`, ```code blocks```, lists). +- NEVER use Markdown tables. Instead, group data under bold headings with bullet points. + For example, instead of a table comparing tiers, format as: + **Trial** + - Deployed Connectors - 2 + - Active Process Flows - 2 + + **Standard** + - Deployed Connectors - 2 + - Active Process Flows - 10 +- Keep responses concise and actionable. +- When listing items, limit to 10 unless asked for more. + +**Documentation knowledge base:** +- You have access to a `search_docs` tool that searches the Patchworks product documentation. +- Use `search_docs` FIRST when the user asks about Patchworks concepts, features, setup, + configuration, pricing tiers, terminology, or how-to questions about the platform. +- Topics covered include: getting started, registration, subscription tiers, quickstart guide, + company setup, users & roles & permissions, marketplace, blueprints, connectors & instances, + process flows, virtual environments, general settings, connector builder, custom scripting, + the Patchworks API, the Patchworks MCP server, and Stockr. +- Do NOT guess at Patchworks-specific answers — always check the docs first. +- Combine documentation results with your general knowledge to give thorough, accurate answers. + +**Tool usage optimization:** +- Try to answer questions with a single tool call when possible. +- Call multiple tools in parallel when they are independent. For example, when investigating + a failure you can call `get_all_flows` and `triage_latest_failures` in the same turn. +- Summarize tool results naturally — don't dump raw JSON. +- When investigating issues, proactively check logs and suggest next steps. +- If a tool returns an error, explain what likely went wrong in plain language. + +**Failure investigation — use `investigate_failure` first:** +- When a user asks "why did X fail?", "what went wrong with X?", or similar, call + `investigate_failure` with the flow name or ID. This single tool call will: + 1. Resolve the flow name to an ID. + 2. Find the run matching the failure timestamp (or the most recent if no timestamp). + 3. Summarise the logs, errors, and highlights. + 4. Return a `run_log_url` dashboard link for the flow run. +- **CRITICAL — Always extract the `failed_at` timestamp:** The conversation history often + contains a Slack alert with a "Failed At" timestamp (e.g. "2026-02-24 13:34:04"). You + MUST extract this timestamp and pass it as the `failed_at` parameter to + `investigate_failure`. This ensures you investigate the EXACT run the alert refers to, + not just the most recent one (which may be a different run entirely). +- By default, `include_payload` is false to keep response times fast. Only set it to true + when the user specifically asks about payload content or you need to follow the alert chain. +- If you need payload content separately, call `get_run_payloads` as a follow-up. +- Only fall back to individual tools (`get_flow_runs`, `get_flow_run_logs`, etc.) if + `investigate_failure` does not return enough detail or the user asks for something specific. +- **ALWAYS** include a dashboard link in your response so the user can view the full logs. + Format it as a clickable Markdown link. +- When the result contains `originating_run_log_url`, use THAT link (not `run_log_url`) because + the originating run is where the real failure happened. The `run_log_url` in this case is + just the alert flow's run, which is less useful. +- When there is NO originating run, use `run_log_url`. + +**Alert / notification flow pattern:** +- Flows with Try/Catch often handle errors internally: the connector call fails, the Catch + branch runs a script and sends a Slack alert, and the overall run finishes as SUCCESS (2) + or PARTIAL_SUCCESS (5) — NOT FAILURE (3). +- This means the run with the real error logs can have ANY status. When a `failed_at` + timestamp is provided, `investigate_failure` searches all statuses (3, 5, 2, 1) and + timestamp-matches to find the exact run. +- Some flows are separate alert flows triggered by another flow's catch route. In that case + `investigate_failure` follows the alert chain automatically via catch-route payloads. +- When the result contains `originating_run_summary` and `originating_run_log_url`, base + your answer on the ORIGINATING run's errors, not the alert flow's logs. +- Always prefer `originating_run_log_url` over `run_log_url` in your response when both + are present. When there is no originating run, use `run_log_url`. + +**Payload retrieval:** +- When a user asks "what payload did we send?", "show me the payload", or similar, use + `get_run_payloads` with the run ID. This fetches all payloads in one call. +- You can filter by step name, e.g. step_name="Catch" for the catch-route payload. +- When `summarise_failed_run` or `investigate_failure` results include `available_payloads`, + those are payload metadata IDs you can retrieve with `download_payload` or `get_run_payloads`. +- For alert flows, the catch-route payload typically contains the error details and a reference + to the original failed run. + +**Suggesting fixes for failures — NEVER fabricate payloads:** +- When a connector call fails (e.g. HTTP 422, 400, 500), do NOT fabricate example payloads, + do NOT guess what the correct request body should be, and do NOT invent "corrected" payload + examples. You WILL get fields wrong and mislead the user. This is a hard rule — no exceptions. +- Instead: clearly explain WHAT failed and WHY (based on the error message and logs), then + tell the user which external system's documentation they should consult (e.g. "Check the + Odoo docs for the sale.order `create` method to see the required `vals_list` format"). +- You CAN show the payload that was actually sent (from the flow run logs) so the user can + compare it against the API documentation themselves. +- If the error is clearly a Patchworks configuration issue (e.g. missing mapping, wrong + endpoint), use `search_docs` to find relevant Patchworks documentation and link to that. + +**CRITICAL — Links in responses:** +- The ONLY links you may include in responses are: + 1. `run_log_url` or `originating_run_log_url` values returned by tools (these are verified). + 2. Links returned by `search_docs` from the Patchworks knowledge base (these have real URLs). +- NEVER fabricate or guess URLs for external documentation (Odoo, Shopify, Slack, etc.). + These URLs are frequently wrong and lead to 404 pages. Instead, tell the user what to + search for by name (e.g. "search the Odoo docs for sale.order create vals_list"). + +**Retrying / starting flows with a payload:** +- When the user asks to "retry with this payload" or "start the flow with X data", use + `start_flow` with the `payload` parameter set to the data the user provided. +- The payload is automatically JSON-stringified before being sent to the Start API, so + you should pass the raw data object — do NOT stringify or wrap it yourself. +- IMPORTANT: If the previous investigation found an ORIGINATING flow (alert chain), retry + the ORIGINATING flow, not the alert flow. The `investigate_failure` result will include + `originating_flow_id` — use THAT as the `flow_id` in `start_flow`. Never use the alert + flow's ID for retrying. +- The alert flow just sends notifications — the originating flow is the one that does the + actual work and needs retrying. +- After starting a flow, tell the user the flow was triggered and include both the + originating flow name/ID and the `originating_run_log_url` so they can monitor it. + +**Timestamps and run identification:** +- Always include the `run_started_at` timestamp in your response when discussing a specific + flow run, so the user can verify which run you're referring to. +- Format timestamps in a human-readable way (e.g. "started at 2026-02-24 14:32:01 UTC"). +- This is especially important when the user asks follow-up questions — it prevents confusion + about which run is being discussed.""" + +# Load tool definitions from file (Anthropic format — canonical source). +# Other providers convert from this format at runtime via providers/tool_converter.py. +_tools_path = Path(__file__).parent / "anthropic_tools.json" +with open(_tools_path) as _f: + ANTHROPIC_TOOLS = json.load(_f) +log.info(f"Loaded {len(ANTHROPIC_TOOLS)} tool definitions from {_tools_path}") + +# Max agentic iterations — raised from 5 to 10 to support complex +# multi-step investigations (e.g. alert chain follow-through). +MAX_TOOL_ITERATIONS = 10 + +# Hard cap on the size of a single tool result string (in characters). +# Keeps conversation history from ballooning and hitting token limits on +# follow-up turns. ~20k chars ≈ ~5k tokens, leaving plenty of headroom. +MAX_TOOL_RESULT_CHARS = 20_000 + + +def _execute_tool(tool_name: str, tool_input: dict) -> str: + """Execute a tool by name using local Python functions. Returns JSON string.""" + + # The Anthropic tool schemas wrap args under an "args" key for PW core tools + args = tool_input.get("args", tool_input) + + try: + # --- Patchworks Core tools --- + if tool_name == "get_all_flows": + result = pw.get_all_flows(**args) + elif tool_name == "get_flow_runs": + result = pw.get_flow_runs(**args) + elif tool_name == "get_flow_run_logs": + result = pw.get_flow_run_logs(**args) + elif tool_name == "summarise_failed_run": + result = pw.summarise_failed_run(**args) + elif tool_name == "triage_latest_failures": + result = pw.triage_latest_failures(**args) + elif tool_name == "download_payload": + ctype, raw = pw.download_payload(args["payload_metadata_id"]) + result = {"content_type": ctype, "bytes_base64": base64.b64encode(raw).decode("ascii")} + elif tool_name == "start_flow": + result = pw.start_flow(**args) + elif tool_name == "list_data_pools": + result = pw.list_data_pools(**args) + elif tool_name == "get_deduped_data": + result = pw.get_deduped_data(**args) + elif tool_name == "create_process_flow_from_prompt": + parts = _guess_parts_from_prompt(args["prompt"]) + body = _build_generic_import_json( + parts["source"], parts["destination"], parts["entity"], + priority=args.get("priority", 3), + schedule_cron=args.get("schedule_cron", "0 * * * *"), + enable=args.get("enable", False), + ) + result = pw.import_flow(body) + elif tool_name == "create_process_flow_from_json": + result = pw.import_flow(args.get("body", {})) + elif tool_name == "investigate_failure": + result = pw.investigate_failure( + flow_id=args.get("flow_id"), + flow_name=args.get("flow_name"), + run_id=args.get("run_id"), + include_payload=args.get("include_payload", False), + failed_at=args.get("failed_at"), + ) + elif tool_name == "get_run_payloads": + result = pw.get_run_payloads( + run_id=args["run_id"], + step_name=args.get("step_name"), + ) + + # --- Documentation search --- + elif tool_name == "search_docs": + idx = _get_docs_index() + q = args.get("query", "") + mr = args.get("max_results", 3) + hits = idx.search(q, max_results=mr) + result = {"results": hits} if hits else {"results": [], "message": "No matching documentation found. Try different keywords."} + + # --- Commerce Foundation tools (pass through as JSON) --- + elif tool_name in ( + "get_customers", "get_products", "get_product_variants", + "get_inventory", "get_returns", "get_fulfillments", "get_orders", + "create_sales_order", "update_order", "cancel_order", + "fulfill_order", "create_return", + ): + cf_fn = getattr(pw, tool_name) + cf_payload = json.dumps(args) if args else None + result = cf_fn(inputSchema=cf_payload) + + else: + result = {"error": f"Unknown tool: {tool_name}"} + + except Exception as e: + log.error(f"Tool execution error for {tool_name}: {e}") + result = {"error": str(e)} + + result_str = json.dumps(result, default=str) + + # Truncate oversized tool results to stay within token budget + if len(result_str) > MAX_TOOL_RESULT_CHARS: + log.warning(f"Tool result for {tool_name} truncated from {len(result_str)} to {MAX_TOOL_RESULT_CHARS} chars") + result_str = result_str[:MAX_TOOL_RESULT_CHARS] + '... [TRUNCATED — result too large. Use more specific queries or ask the user to narrow scope.]' + + return result_str + + +from providers import get_provider + +async def _run_chat(message: str, conversation_history: list[dict] | None = None, provider_name: str | None = None) -> str: + """Full agentic loop: sends message to LLM provider, executes any tool calls, returns final response.""" + provider = get_provider(provider_name) + + messages = list(conversation_history or []) + messages.append({"role": "user", "content": message}) + + return await provider.run_chat( + messages=messages, + system_prompt=SYSTEM_PROMPT, + tools=ANTHROPIC_TOOLS, + tool_executor=_execute_tool, + max_iterations=MAX_TOOL_ITERATIONS, + ) + + +async def chat_endpoint(request: Request) -> JSONResponse: + """POST /chat — accepts message + conversation history, returns final response. + + Optional ``provider`` field in the JSON body selects the LLM backend: + "anthropic" (default), "openai", or "gemini". + """ + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + + message = body.get("message", "").strip() + if not message: + return JSONResponse({"error": "No message provided"}, status_code=400) + + conversation_history = body.get("conversation_history", []) + provider_name = body.get("provider") # None → falls back to env / default + + try: + response_text = await _run_chat(message, conversation_history, provider_name) + return JSONResponse({"response": response_text}) + except Exception as e: + log.error(f"Chat endpoint error: {e}", exc_info=True) + return JSONResponse( + {"error": f"Failed to process message: {str(e)}"}, + status_code=500, + ) -# We assume Address, Customer, and CustomField are imported or available -# from the previous definition. -class UpdateOrderLineItem(BaseModel): - """ - Specific LineItem definition for updates. - Differs from the create schema by requiring 'unitPrice'. - """ - id: Optional[str] = Field(None, description="Unique identifier for this line item within the order") - sku: str = Field(..., min_length=1, description="Product Variant SKU") - quantity: float = Field(..., minimum=1, description="Quantity ordered") - unitPrice: float = Field(..., minimum=0, description="Price per unit") # Required in this schema - unitDiscount: Optional[float] = Field(None, minimum=0, description="Discount per unit") - totalPrice: Optional[float] = Field(None, minimum=0, description="Total price for the line item. Calculated as (unitPrice - unitDiscount) * quantity") - name: Optional[str] = Field(None, description="Product name for display") - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class OrderUpdates(BaseModel): - """ - Fields allowed to be updated on an order. - """ - externalId: Optional[str] = Field(None, description="ID of the entity in the client's system. Must be unique within the tenant.") - tenantId: Optional[str] = Field(None, description="Unique identifier for the tenant that owns this entity (read-only)") - name: Optional[str] = Field(None, description="Order name") - status: Optional[str] = Field(None, description="Order status") - billingAddress: Optional[Address] = Field(None, description="Billing address") - currency: Optional[str] = Field(None, description="Order currency code") - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - customer: Optional[Customer] = Field(None, description="Order customer information") - discounts: Optional[List[Dict[str, Any]]] = Field(None, description="Discounts") - lineItems: Optional[List[UpdateOrderLineItem]] = Field(None, description="List of line items to update") - orderDiscount: Optional[float] = Field(None, description="Order Discount") - orderNote: Optional[str] = Field(None, description="Order Notes") - orderSource: Optional[str] = Field(None, description="The original order platform, walmart, etsy, etc") - orderTax: Optional[float] = Field(None, description="Order Tax") - paymentStatus: Optional[str] = Field(None, description="status of the payment") - payments: Optional[List[Dict[str, Any]]] = Field(None, description="Payments") - refunds: Optional[List[Dict[str, Any]]] = Field(None, description="Refunds") - subTotalPrice: Optional[float] = Field(None, description="Sub Total Price") - tags: Optional[List[str]] = Field(None, description="Tags for categorization and filtering.") - totalPrice: Optional[float] = Field(None, description="Total Price") - shippingAddress: Optional[Address] = Field(None, description="Shipping address") - shippingCarrier: Optional[str] = Field(None, description="Shipping carrier name eg. UPS, FedEx, USPS") - shippingClass: Optional[str] = Field(None, description="Service level e.g. Next Day, Express, Ground") - shippingCode: Optional[str] = None - shippingNote: Optional[str] = Field(None, description="Additional shipping notes") - shippingPrice: Optional[float] = Field(None, description="Shipping cost") - giftNote: Optional[str] = None - incoterms: Optional[str] = None - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class UpdateSalesOrderArgs(BaseModel): - """ - Input schema for updating an order. - Matches the schema title 'update-order'. - """ - id: str = Field(..., description="Order ID") - updates: OrderUpdates = Field(..., description="Fields to update") - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') +# ------------------------------------------------------------------------------ +# Server Startup - Dual Mode (stdio for Claude Desktop, HTTP for ngrok/flows) +# ------------------------------------------------------------------------------ +# IMPORTANT: is_port_available, HostRewriteMiddleware, and run_server are defined +# at module level (not inside __main__) so that macOS multiprocessing (spawn mode) +# can pickle and locate them in child processes. +# ------------------------------------------------------------------------------ -@mcp.tool() -def update_order(args: UpdateSalesOrderArgs) -> Any: - """update Sales Order as per JSON args in the input schema https://raw.githubusercontent.com/commerce-operations-foundation/mcp-reference-server/refs/heads/develop/schemas/tool-inputs/update-order.json. If no args is provided, error""" - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.update_order(inputSchema=json_string_payload) - - -# ----------------------------------------------------------------------------- -# Commerce Operations Foundation - Cancel Sales Order -# ----------------------------------------------------------------------------- - -class CancelOrderLineItem(BaseModel): - """ - Specific line item definition for cancellation requests. - Includes only the fields necessary to identify the item and quantity to cancel. - """ - sku: str = Field(..., min_length=1, description="Product Variant SKU") - quantity: float = Field(..., minimum=1, description="Quantity ordered") - id: Optional[str] = Field(None, description="Unique identifier for this line item within the order") +def is_port_available(port: int) -> bool: + """Check if a port is available for binding.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(('0.0.0.0', port)) + return True + except OSError: + return False - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') -class CancelSalesOrderArgs(BaseModel): - """ - Input schema for canceling an order. - Matches the schema title 'cancel-order'. - """ - orderId: str = Field(..., description="id of the order to cancel") - reason: Optional[str] = Field(None, description="Reason for cancellation") - notifyCustomer: Optional[bool] = Field(None, description="Whether to send cancellation notification to customer") - notes: Optional[str] = Field(None, description="Additional cancellation notes") - lineItems: Optional[List[CancelOrderLineItem]] = Field(None, description="Specific line items to cancel (omit to cancel entire order)") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -@mcp.tool() -def cancel_order(args: CancelSalesOrderArgs) -> Any: - """Create Sales Order as per JSON args in the input schema https://raw.githubusercontent.com/commerce-operations-foundation/mcp-reference-server/refs/heads/develop/schemas/tool-inputs/cancel-order.json. If no args is provided, error""" - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.cancel_order(inputSchema=json_string_payload) - -# ----------------------------------------------------------------------------- -# Commerce Operations Foundation - Fulfill Sales Order -# ----------------------------------------------------------------------------- -class FulfillmentLineItem(BaseModel): - """ - Specific line item definition for fulfillment. - Matches the schema within 'fulfill-order'. - """ - id: Optional[str] = Field(None, description="Unique identifier for this line item within the order") - sku: str = Field(..., min_length=1, description="Product Variant SKU") - quantity: float = Field(..., minimum=1, description="Quantity ordered") - unitPrice: Optional[float] = Field(None, minimum=0, description="Price per unit") - unitDiscount: Optional[float] = Field(None, minimum=0, description="Discount per unit") - totalPrice: Optional[float] = Field(None, minimum=0, description="Total price for the line item. Calculated as (unitPrice - unitDiscount) * quantity") - name: Optional[str] = Field(None, description="Product name for display") - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class FulfillOrderArgs(BaseModel): - """ - Input schema for fulfilling an order. - Matches the schema title 'fulfill-order'. - """ - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - expectedDeliveryDate: Optional[str] = Field(None, description="Expected delivery date", pattern=DATE_PATTERN) - expectedShipDate: Optional[str] = Field(None, description="Expected date the order will be shipped", pattern=DATE_PATTERN) - lineItems: List[FulfillmentLineItem] = Field(..., description="Items included in this fulfillment") - locationId: Optional[str] = None - orderId: str = Field(..., description="Order ID") - shipByDate: Optional[str] = Field(None, pattern=DATE_PATTERN) - status: Optional[str] = None - tags: Optional[List[str]] = Field(None, description="Tags for categorization and filtering.") - trackingNumbers: List[str] = Field(..., description="Tracking numbers from carrier") - shippingAddress: Optional[Address] = Field(None, description="Shipping address") - shippingCarrier: Optional[str] = Field(None, description="Shipping carrier name eg. UPS, FedEx, USPS") - shippingClass: Optional[str] = Field(None, description="Service level e.g. Next Day, Express, Ground") - shippingCode: Optional[str] = None - shippingNote: Optional[str] = Field(None, description="Additional shipping notes") - shippingPrice: Optional[float] = Field(None, description="Shipping cost") - giftNote: Optional[str] = None - incoterms: Optional[str] = None - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -@mcp.tool() -def fulfill_order(args: FulfillOrderArgs) -> Any: - """Create Sales Order as per JSON args in the input schema https://raw.githubusercontent.com/commerce-operations-foundation/mcp-reference-server/refs/heads/develop/schemas/tool-inputs/cancel-order.json. If no args is provided, error""" - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.fulfill_order(inputSchema=json_string_payload) - -# ----------------------------------------------------------------------------- -# Commerce Operations Foundation - Create Return -# ----------------------------------------------------------------------------- -from typing import List, Optional -from pydantic import BaseModel, Field, ConfigDict +class HostRewriteMiddleware: + def __init__(self, app): + self.app = app -# We assume Address, CustomField, and DATE_PATTERN are available from previous definitions. + async def __call__(self, scope, receive, send): + if scope["type"] == "http": + headers = dict(scope["headers"]) + scope["headers"] = list(headers.items()) + await self.app(scope, receive, send) -class Inspection(BaseModel): - """ - Item condition grade and disposition details. - """ - conditionCategory: Optional[str] = Field(None, description="Item condition grade after inspection") - dispositionOutcome: Optional[str] = Field(None, description="Disposition decision for the returned item") - warehouseLocationId: Optional[str] = Field(None, description="Warehouse bin/shelf location identifier for restocking") - note: Optional[str] = Field(None, description="Inspection notes about item condition and disposition") - inspectedBy: Optional[str] = Field(None, description="Who inspected the item") - inspectedAt: Optional[str] = Field(None, description="When item was inspected", pattern=DATE_PATTERN) - images: Optional[List[str]] = Field(None, description="Photos of returned item condition") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class ReturnLineItem(BaseModel): - """ - Items being returned. - """ - id: Optional[str] = Field(None, description="Unique identifier for this return line item") - orderLineItemId: str = Field(..., description="Reference to the original order line item") - sku: str = Field(..., description="Product Variant SKU") - quantityReturned: float = Field(..., minimum=1, description="Quantity being returned") - returnReason: str = Field(..., description='Primary return reason code (e.g., "defective", "wrong_item", "no_longer_needed", "size_issue", "quality_issue")') - inspection: Optional[Inspection] = None - unitPrice: Optional[float] = Field(None, description="Original unit price from order") - refundAmount: Optional[float] = Field(None, minimum=0, description="Refund amount for this line item") - restockFee: Optional[float] = Field(None, minimum=0, description="Restocking fee charged for this line item") - name: Optional[str] = Field(None, description="Product name for display") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class ExchangeLineItem(BaseModel): - """ - Items being exchanged. - """ - id: Optional[str] = Field(None, description="Unique exchange line item identifier") - exchangeOrderId: Optional[str] = Field(None, description="Order ID created for this exchange") - exchangeOrderName: Optional[str] = Field(None, description="Order number/name for exchange order") - sku: str = Field(..., description="Product Variant SKU") - name: Optional[str] = Field(None, description="Product name") - quantity: float = Field(..., minimum=1, description="Quantity requested") - unitPrice: Optional[float] = Field(None, description="Unit price") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class ReturnMethod(BaseModel): - """ - Method customer uses to return items. - """ - provider: Optional[str] = Field(None, description="Return logistics provider") - methodType: Optional[str] = Field(None, description="Method customer uses to return items") - address: Optional[Address] = Field(None, description="Address where customer returns items") - qrCodeUrl: Optional[str] = Field(None, description="QR code URL for label-free return methods") - updatedAt: Optional[str] = Field(None, pattern=DATE_PATTERN) - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') +def run_server(port: int): + """Run uvicorn on the given port. Must be top-level for macOS spawn multiprocessing.""" + mcp_app = mcp.streamable_http_app() -class ReturnLabel(BaseModel): - """ - Shipping labels for this return. - """ - status: Optional[str] = Field(None, description="Label lifecycle status") - carrier: str = Field(..., description="Shipping carrier providing the label") - trackingNumber: str = Field(..., description="Tracking number for the return shipment") - url: Optional[str] = Field(None, description="URL to download the shipping label") - rate: Optional[float] = Field(None, description="Shipping cost for this label") - createdAt: Optional[str] = Field(None, pattern=DATE_PATTERN) - updatedAt: Optional[str] = Field(None, pattern=DATE_PATTERN) - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') - -class CreateReturnArgs(BaseModel): - """ - Input schema for creating a Return. - Matches the schema title 'Return'. - """ - id: str = Field(..., description="Unique system-generated identifier for this entity (read-only)") - externalId: Optional[str] = Field(None, description="ID of the entity in the client's system. Must be unique within the tenant.") - createdAt: str = Field(..., description="ISO 8601 timestamp when the entity was created (read-only)", pattern=DATE_PATTERN) - updatedAt: str = Field(..., description="ISO 8601 timestamp when the entity was last updated (read-only)", pattern=DATE_PATTERN) - tenantId: str = Field(..., description="Unique identifier for the tenant that owns this entity (read-only)") - returnNumber: Optional[str] = Field(None, description='Customer-facing return identifier used for tracking and reference (e.g., "RET-12345")') - orderId: str = Field(..., description="ID of the original order being returned") - status: Optional[str] = Field(None, description="Return processing status in the return lifecycle") - outcome: str = Field(..., description="What the customer receives for their return") - returnLineItems: List[ReturnLineItem] = Field(..., description="Items being returned") - exchangeLineItems: Optional[List[ExchangeLineItem]] = Field(None, description="Items being exchanged") - totalQuantity: Optional[float] = Field(None, description="Total quantity of items being returned (excludes exchange items)") - returnMethod: Optional[ReturnMethod] = None - returnShippingAddress: Optional[Address] = Field(None, description="Address where items should be returned to") - labels: Optional[List[ReturnLabel]] = Field(None, description="Shipping labels for this return") - locationId: Optional[str] = Field(None, description="Warehouse facility identifier where return will be received") - returnTotal: Optional[float] = Field(None, description="Gross merchandise value of returned items before fees") - exchangeTotal: Optional[float] = Field(None, description="Gross merchandise value of exchange items before any credits applied") - refundAmount: Optional[float] = Field(None, description="Final refund amount to customer after fees and restocking charges") - refundMethod: Optional[str] = Field(None, description="Payment method for issuing the refund") - refundStatus: Optional[str] = Field(None, description="Payment refund processing status (separate from return status)") - refundTransactionId: Optional[str] = Field(None, description="Transaction ID for the refund") - shippingRefundAmount: Optional[float] = Field(None, description="Amount of original shipping cost being refunded") - returnShippingFees: Optional[float] = Field(None, description="Return shipping cost charged to customer (if applicable)") - restockingFee: Optional[float] = Field(None, description="Total restocking fees charged to customer across all items") - requestedAt: Optional[str] = Field(None, description="When return was requested", pattern=DATE_PATTERN) - receivedAt: Optional[str] = Field(None, description="When returned items were received", pattern=DATE_PATTERN) - completedAt: Optional[str] = Field(None, description="When return was fully processed", pattern=DATE_PATTERN) - customerNote: Optional[str] = Field(None, description="Customer notes about the return") - internalNote: Optional[str] = Field(None, description="Internal notes for staff") - returnInstructions: Optional[str] = Field(None, description="Instructions provided to customer") - declineReason: Optional[str] = Field(None, description="Reason if return was declined") - statusPageUrl: Optional[str] = Field(None, description="Customer-facing status tracking page") - tags: Optional[List[str]] = Field(None, description='Tags for categorization and filtering.') - customFields: Optional[List[CustomField]] = Field(None, description="Custom Fields") - - model_config = ConfigDict(extra='forbid') - model_config = ConfigDict(regex_engine='python-re') + @asynccontextmanager + async def lifespan(app): + async with mcp_app.router.lifespan_context(app): + yield + app = Starlette( + routes=[ + Route("/chat", chat_endpoint, methods=["POST"]), + Mount("/", app=mcp_app), + ], + lifespan=lifespan, + ) + app = HostRewriteMiddleware(app) + log.info(f"Starting HTTP server on port {port}") + uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") -@mcp.tool() -def create_return(args: CreateReturnArgs) -> Any: - """Create Sales Order as per JSON args in the input schema https://raw.githubusercontent.com/commerce-operations-foundation/mcp-reference-server/refs/heads/develop/schemas/tool-inputs/cancel-order.json. If no args is provided, error""" - # 1. Convert the Pydantic model to a clean dictionary - # exclude_none=True ensures we don't send "locationIds": null if it wasn't provided - query_data = args.model_dump(exclude_none=True) - - # 2. Serialize that dictionary into a JSON string - json_string_payload = json.dumps(query_data) - - # 3. Pass the JSON string to your service method - return pw.create_return(inputSchema=json_string_payload) if __name__ == "__main__": - mcp.run(transport="stdio") + if is_port_available(8000): + log.info("Port 8000 available - starting HTTP servers on ports 8000 and 8001") + log.info("Port 8000: For ngrok tunnel and flows") + log.info("Port 8001: For alternative HTTP access") + + ports = [8000, 8001] + processes = [] + + for port in ports: + p = multiprocessing.Process(target=run_server, args=(port,)) + p.start() + processes.append(p) + log.info(f"✓ Started server process on port {port} (PID: {p.pid})") + + try: + for p in processes: + p.join() + except KeyboardInterrupt: + log.info("Shutting down HTTP servers...") + for p in processes: + p.terminate() + p.join() + log.info("All servers stopped") + else: + # stdio mode — port 8000 is in use, assume Claude Desktop + log.info("Port 8000 in use - running in stdio mode for Claude Desktop") + log.info("HTTP servers will not start (using stdio transport instead)") + mcp.run()