`.\n\n**Reading a board**: `miro_list_items` returns every item (filter by `type` or by frame with `parent_item_id`), `miro_list_connectors` the lines between them. Both page with a `cursor` and take 10 to 50 per page. Boards, members and tags page with `offset` and `limit`.\n\n**Tags**: only cards and sticky notes can carry tags, at most 8 each. Create the tag (`miro_create_tag`), then `miro_attach_tag`; `miro_list_items_by_tag` finds the tagged items. Tag changes made through the API appear on an open board after a refresh.\n\n**Deleting**: `miro_delete_item` removes any item for good (there is no undo through the API); deleting a frame leaves its contents on the board. `miro_delete_tag` removes the tag from every item. Confirm deletions with the user unless the AI just created the item in the same task.\n\n**Plans**: the API is free to use. Free teams may hold only 3 editable boards, so `miro_create_board` and `miro_copy_board` can fail there; boards of a developer team carry a watermark.\n\n**Rate limits**: Miro counts credits per user and app, 100,000 a minute: a read costs 50, most writes 100, creating or deleting boards and deleting items 500, copying a board 2,000. Over the limit Miro answers 429; wait a minute.\n\n**Errors**: 401 (`tokenNotProvided`) means the authorization expired or was revoked: click **Authorize with Provider** again. 403 means a missing permission (`boards:write` for changes) or no access to that board. 404 is a wrong board or item id (or one passed as a number). 400 (`invalidParameters`) names the field, e.g. a sticky note with both width and height, a hex color on a sticky note, or a frame used as a connector end. 409 means the item changed at the same time; read it again and retry.\n\n**Out of scope here**: images, documents and embeds (read only through `miro_list_items`), app cards, comments, mind maps, exports, and team or organization administration.",
+ "region": "intl",
+ "category": "productivity",
+ "icon": "miro",
+ "docsUrl": "https://developers.miro.com/reference/api-reference",
+ "requiredEnvVars": [
+ "MIRO_CLIENT_ID",
+ "MIRO_CLIENT_SECRET"
+ ],
+ "appRegistrationUrl": "https://miro.com/app/settings/user-profile/apps",
+ "envVarMeta": {
+ "MIRO_CLIENT_ID": {
+ "label": "Client ID",
+ "kind": "credential",
+ "help": "Miro → avatar → Settings → Your apps → your app → App Credentials: Client ID.",
+ "link": "https://miro.com/app/settings/user-profile/apps",
+ "pattern": "^[0-9]{6,}$",
+ "patternMessage": "The Client ID is a long number, e.g. 3458764512345678901."
+ },
+ "MIRO_CLIENT_SECRET": {
+ "label": "Client secret",
+ "kind": "credential",
+ "secret": true,
+ "help": "Same page → App Credentials: Client secret (click to reveal and copy).",
+ "link": "https://miro.com/app/settings/user-profile/apps"
+ }
+ },
+ "verifyHints": {
+ "401": "Miro refused the access token. Click Authorize with Provider again; if the app was deleted or its secret reset, update MIRO_CLIENT_SECRET first.",
+ "403": "The app lacks the boards:read or boards:write permission, or the user has no access to this board. Tick the permissions in the app settings and authorize again.",
+ "rate_limited": "Miro is rate limiting this user. Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "miro_get_token_info"
+ },
+ "prerequisites": "A Miro account (the free plan works) and a Miro app in a developer team (free, about 5 minutes) with the redirect URI below, the permissions boards:read and boards:write, and expiring tokens turned on when the app is created.",
+ "connector": {
+ "name": "Miro REST API v2",
+ "type": "REST",
+ "baseUrl": "https://api.miro.com/v2",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "clientId": "{{MIRO_CLIENT_ID}}",
+ "clientSecret": "{{MIRO_CLIENT_SECRET}}",
+ "authorizationUrl": "https://miro.com/oauth/authorize",
+ "tokenUrl": "https://api.miro.com/v1/oauth/token"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/boards?limit=1"
+ },
+ "tools": [
+ {
+ "name": "miro_get_token_info",
+ "description": "Show who the connection belongs to: the Miro user, the team the app was installed in, the organization and the granted scopes. A cheap check that the authorization works.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "https://api.miro.com/v1/oauth-token"
+ }
+ },
+ {
+ "name": "miro_list_boards",
+ "description": "Find boards the user can open: by text, owner, team or project. Returns id, name, description, owner, team, viewLink, createdAt and modifiedAt, with total for paging by offset. Recently created boards can take a few seconds to show up in a search.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Text to search in board names and descriptions."
+ },
+ "team_id": {
+ "type": "string",
+ "description": "Only boards of this team (then query and owner are ignored)."
+ },
+ "project_id": {
+ "type": "string",
+ "description": "Only boards in this project (space)."
+ },
+ "owner": {
+ "type": "string",
+ "description": "Only boards owned by this user id."
+ },
+ "sort": {
+ "type": "string",
+ "enum": [
+ "default",
+ "last_modified",
+ "last_opened",
+ "last_created",
+ "alphabetically"
+ ],
+ "description": "default, last_modified, last_opened, last_created or alphabetically (applies when filtering by team or project)."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "description": "Boards per page, 1 to 50 (default 20)."
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "How many boards to skip, for paging (default 0)."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards",
+ "queryParams": {
+ "query": "$query",
+ "team_id": "$team_id",
+ "project_id": "$project_id",
+ "owner": "$owner",
+ "sort": "$sort",
+ "limit": "$limit",
+ "offset": "$offset"
+ }
+ }
+ },
+ {
+ "name": "miro_get_board",
+ "description": "Get one board: name, description, owner, team, project, sharing and permission policies, viewLink, createdAt and modifiedAt.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_create_board",
+ "description": "Create a board. Returns the new board with its id and viewLink. Free plans allow only 3 editable boards per team. Confirm with the user first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Board name, up to 60 characters (default Untitled)."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description, up to 300 characters."
+ },
+ "team_id": {
+ "type": "string",
+ "description": "Team to create it in; the user's default team otherwise."
+ },
+ "project_id": {
+ "type": "string",
+ "description": "Project (space) to put it in."
+ },
+ "policy": {
+ "type": "object",
+ "description": "Sharing and permissions, e.g. {\"sharingPolicy\": {\"access\": \"private\", \"teamAccess\": \"edit\"}, \"permissionsPolicy\": {\"copyAccess\": \"team_members\"}}. access and teamAccess: private, view, comment or edit."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards",
+ "bodyMapping": {
+ "name": "$name",
+ "description": "$description",
+ "teamId": "$team_id",
+ "projectId": "$project_id",
+ "policy": "$policy"
+ }
+ }
+ },
+ {
+ "name": "miro_update_board",
+ "description": "Rename a board, change its description or its sharing policy. Only the fields given change.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "name": {
+ "type": "string",
+ "description": "New name, up to 60 characters."
+ },
+ "description": {
+ "type": "string",
+ "description": "New description, up to 300 characters."
+ },
+ "policy": {
+ "type": "object",
+ "description": "Sharing and permissions, e.g. {\"sharingPolicy\": {\"access\": \"private\", \"teamAccess\": \"edit\"}, \"permissionsPolicy\": {\"copyAccess\": \"team_members\"}}. access and teamAccess: private, view, comment or edit."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name",
+ "description": "$description",
+ "policy": "$policy"
+ }
+ }
+ },
+ {
+ "name": "miro_copy_board",
+ "description": "Copy a board with all its content into a new board, optionally with a new name and in another team. Returns the new board. Confirm with the user first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the copy."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of the copy."
+ },
+ "team_id": {
+ "type": "string",
+ "description": "Team to put the copy in."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/boards?copy_from={board_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "name": "$name",
+ "description": "$description",
+ "teamId": "$team_id"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": false,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "miro_delete_board",
+ "enabled": false,
+ "description": "Delete a whole board with everything on it. On paid plans it stays in the trash for 90 days. Switched off at install.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/boards/{board_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_list_items",
+ "description": "List the items on a board: id, type, data (content, title ...), style, position, geometry, parent (frame) and who changed them when. Filter by type or by frame (parent_item_id). Pages with cursor: pass the cursor of the answer until none comes back.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "sticky_note",
+ "shape",
+ "text",
+ "card",
+ "app_card",
+ "frame",
+ "image",
+ "document",
+ "embed",
+ "preview"
+ ],
+ "description": "Only items of this type."
+ },
+ "parent_item_id": {
+ "type": "string",
+ "description": "Only items inside this frame."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 10,
+ "maximum": 50,
+ "description": "Items per page, 10 to 50 (default 10)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous page."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/items",
+ "encodePathParams": true,
+ "queryParams": {
+ "type": "$type",
+ "parent_item_id": "$parent_item_id",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "miro_get_item",
+ "description": "Get one item of any type (sticky note, shape, text, card, frame, image ...): type, data, style, position, geometry and parent.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/items/{item_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_move_item",
+ "description": "Move any item: to a new position, into a frame (parent {\"id\": \" \"}) or out of it back onto the board (parent {\"id\": null}).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "parent": {
+ "type": "object",
+ "description": "New frame {\"id\": \" \"}, or {\"id\": null} to take it out of its frame."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/items/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "position": "$position",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_delete_item",
+ "description": "Delete an item of any type (sticky note, shape, text, card, frame, image ...). Deleting a frame does not delete the items inside it. Confirm with the user before deleting anything they did not just create.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/boards/{board_id}/items/{item_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_create_sticky_note",
+ "description": "Add a sticky note to a board. Returns the note with its id. Content may use simple HTML (, , ).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "content": {
+ "type": "string",
+ "description": "Text of the note."
+ },
+ "shape": {
+ "type": "string",
+ "enum": [
+ "square",
+ "rectangle"
+ ],
+ "description": "square (default) or rectangle."
+ },
+ "style": {
+ "type": "object",
+ "description": "Look: {\"fillColor\": \"light_yellow\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. fillColor is a name: gray, light_yellow (default), yellow, orange, light_green, green, dark_green, cyan, light_pink, pink, violet, red, light_blue, blue, dark_blue or black. textAlign left, center or right; textAlignVertical top, middle or bottom."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 200} or {\"height\": 200}, never both (sticky notes keep their aspect ratio)."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id",
+ "content"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/sticky_notes",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "content": "$content",
+ "shape": "$shape"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_update_sticky_note",
+ "description": "Change a sticky note's text, shape, color, position, size or frame. Only the fields given change.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ },
+ "content": {
+ "type": "string",
+ "description": "New text."
+ },
+ "shape": {
+ "type": "string",
+ "enum": [
+ "square",
+ "rectangle"
+ ],
+ "description": "square or rectangle."
+ },
+ "style": {
+ "type": "object",
+ "description": "Look: {\"fillColor\": \"light_yellow\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. fillColor is a name: gray, light_yellow (default), yellow, orange, light_green, green, dark_green, cyan, light_pink, pink, violet, red, light_blue, blue, dark_blue or black. textAlign left, center or right; textAlignVertical top, middle or bottom."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 200} or {\"height\": 200}, never both (sticky notes keep their aspect ratio)."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/sticky_notes/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "content": "$content",
+ "shape": "$shape"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_create_shape",
+ "description": "Add a shape (rectangle, circle, flowchart symbol ...) with optional text to a board. Returns the shape with its id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "shape": {
+ "type": "string",
+ "default": "rectangle",
+ "description": "Shape: rectangle (default), round_rectangle, circle, triangle, rhombus, parallelogram, trapezoid, pentagon, hexagon, octagon, star, cloud, cross, can, right_arrow, left_arrow, left_right_arrow, left_brace, right_brace, wedge_round_rectangle_callout, or a flowchart shape such as flow_chart_process, flow_chart_decision, flow_chart_terminator."
+ },
+ "content": {
+ "type": "string",
+ "description": "Text inside the shape; simple HTML allowed."
+ },
+ "style": {
+ "type": "object",
+ "description": "Look, all values as strings: {\"fillColor\": \"#fff9b1\", \"fillOpacity\": \"1\", \"borderColor\": \"#1a1a1a\", \"borderWidth\": \"2\", \"borderStyle\": \"normal\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\", \"fontFamily\": \"arial\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. Colors are hex; borderStyle normal, dotted or dashed; fontSize 10 to 288."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/shapes",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "shape": "$shape",
+ "content": "$content"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_update_shape",
+ "description": "Change a shape's type, text, style, position, size or frame. Only the fields given change.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ },
+ "shape": {
+ "type": "string",
+ "description": "New shape: rectangle (default), round_rectangle, circle, triangle, rhombus, parallelogram, trapezoid, pentagon, hexagon, octagon, star, cloud, cross, can, right_arrow, left_arrow, left_right_arrow, left_brace, right_brace, wedge_round_rectangle_callout, or a flowchart shape such as flow_chart_process, flow_chart_decision, flow_chart_terminator."
+ },
+ "content": {
+ "type": "string",
+ "description": "New text."
+ },
+ "style": {
+ "type": "object",
+ "description": "Look, all values as strings: {\"fillColor\": \"#fff9b1\", \"fillOpacity\": \"1\", \"borderColor\": \"#1a1a1a\", \"borderWidth\": \"2\", \"borderStyle\": \"normal\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\", \"fontFamily\": \"arial\", \"textAlign\": \"center\", \"textAlignVertical\": \"middle\"}. Colors are hex; borderStyle normal, dotted or dashed; fontSize 10 to 288."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/shapes/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "shape": "$shape",
+ "content": "$content"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_create_text",
+ "description": "Add a free text item to a board (a heading, a label, a paragraph). Returns it with its id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "content": {
+ "type": "string",
+ "description": "The text; simple HTML allowed."
+ },
+ "style": {
+ "type": "object",
+ "description": "Look, values as strings: {\"color\": \"#1a1a1a\", \"fontSize\": \"24\", \"fontFamily\": \"arial\", \"textAlign\": \"left\", \"fillColor\": \"#ffffff\", \"fillOpacity\": \"1\"}."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "{\"width\": 400} and optionally \"rotation\"; the height follows the text. The width must be at least 1.7 times the font size."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id",
+ "content"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/texts",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "content": "$content"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_update_text",
+ "description": "Change a text item. Miro wants the text on every update, so pass the current content when only changing style, position or size.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ },
+ "content": {
+ "type": "string",
+ "description": "The text (new or current)."
+ },
+ "style": {
+ "type": "object",
+ "description": "Look, values as strings: {\"color\": \"#1a1a1a\", \"fontSize\": \"24\", \"fontFamily\": \"arial\", \"textAlign\": \"left\", \"fillColor\": \"#ffffff\", \"fillOpacity\": \"1\"}."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "{\"width\": 400} and optionally \"rotation\"; the height follows the text. The width must be at least 1.7 times the font size."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id",
+ "content"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/texts/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "content": "$content"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_create_card",
+ "description": "Add a card (a task-like item with title, description, assignee and due date) to a board. Returns it with its id. Cards and sticky notes are the items that can carry tags.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "title": {
+ "type": "string",
+ "description": "Card title; simple HTML allowed."
+ },
+ "description": {
+ "type": "string",
+ "description": "Card description."
+ },
+ "assignee_id": {
+ "type": "string",
+ "description": "User id of the assignee (from miro_list_board_members)."
+ },
+ "due_date": {
+ "type": "string",
+ "description": "Due date and time, ISO 8601 in UTC, e.g. 2026-10-31T17:00:00Z."
+ },
+ "style": {
+ "type": "object",
+ "description": "{\"cardTheme\": \"#2d9bf0\"}: the card's accent color in hex."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/cards",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "title": "$title",
+ "description": "$description",
+ "assigneeId": "$assignee_id",
+ "dueDate": "$due_date"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_update_card",
+ "description": "Change a card's title, description, assignee, due date, color, position, size or frame. Only the fields given change.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ },
+ "title": {
+ "type": "string",
+ "description": "Card title; simple HTML allowed."
+ },
+ "description": {
+ "type": "string",
+ "description": "Card description."
+ },
+ "assignee_id": {
+ "type": "string",
+ "description": "User id of the assignee (from miro_list_board_members)."
+ },
+ "due_date": {
+ "type": "string",
+ "description": "Due date and time, ISO 8601 in UTC, e.g. 2026-10-31T17:00:00Z."
+ },
+ "style": {
+ "type": "object",
+ "description": "{\"cardTheme\": \"#2d9bf0\"}: the card's accent color in hex."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 300, \"height\": 200} in board units; shapes, cards and frames also take \"rotation\" in degrees."
+ },
+ "parent": {
+ "type": "object",
+ "description": "Frame to put the item in: {\"id\": \" \"}."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/cards/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "title": "$title",
+ "description": "$description",
+ "assigneeId": "$assignee_id",
+ "dueDate": "$due_date"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry",
+ "parent": "$parent"
+ }
+ }
+ },
+ {
+ "name": "miro_create_frame",
+ "description": "Add a frame (a titled area that groups items) to a board. Returns it with its id; put items into it with their parent field or miro_move_item. Frames cannot sit inside other frames here. Miro sometimes answers a valid request with 500 \"Internal error occurred\" for several minutes; wait a minute and try the same request again instead of changing it.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "title": {
+ "type": "string",
+ "description": "Frame title."
+ },
+ "style": {
+ "type": "object",
+ "description": "{\"fillColor\": \"#ffffff\"}: background color in hex; transparent when left out."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 1200, \"height\": 800}."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/frames",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "title": "$title",
+ "format": "custom",
+ "type": "freeform"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry"
+ }
+ }
+ },
+ {
+ "name": "miro_update_frame",
+ "description": "Change a frame's title, background, position or size. Only the fields given change; the items inside move with the frame.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Item id, a long number as a string, e.g. \"3458764517517819000\". Keep it a string."
+ },
+ "title": {
+ "type": "string",
+ "description": "New title."
+ },
+ "style": {
+ "type": "object",
+ "description": "{\"fillColor\": \"#ffffff\"}: background color in hex; transparent when left out."
+ },
+ "position": {
+ "type": "object",
+ "description": "Where the item's center goes, in board units: {\"x\": 0, \"y\": 0} is the board center; x grows to the right, y downwards. Inside a frame (parent), relative to the frame's top-left corner."
+ },
+ "geometry": {
+ "type": "object",
+ "description": "Size: {\"width\": 1200, \"height\": 800}."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/frames/{item_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "data": {
+ "title": "$title"
+ },
+ "style": "$style",
+ "position": "$position",
+ "geometry": "$geometry"
+ }
+ }
+ },
+ {
+ "name": "miro_list_connectors",
+ "description": "List the connector lines on a board: id, startItem and endItem (item ids), shape, captions and style. Pages with cursor.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 10,
+ "maximum": 50,
+ "description": "Connectors per page, 10 to 50 (default 10)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous page."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/connectors",
+ "encodePathParams": true,
+ "queryParams": {
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "miro_get_connector",
+ "description": "Get one connector line: the items it joins, its shape, captions and style.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "connector_id": {
+ "type": "string",
+ "description": "Connector id."
+ }
+ },
+ "required": [
+ "board_id",
+ "connector_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/connectors/{connector_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_create_connector",
+ "description": "Draw a connector line between two items (sticky notes, shapes, cards, texts; not frames), e.g. to link steps of a flowchart. Returns it with its id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "start_item": {
+ "type": "object",
+ "description": "Where the line starts: {\"id\": \"- \", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"
- \", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected."
+ },
+ "end_item": {
+ "type": "object",
+ "description": "Where the line ends, another item: {\"id\": \"
- \", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"
- \", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected."
+ },
+ "shape": {
+ "type": "string",
+ "enum": [
+ "straight",
+ "elbowed",
+ "curved"
+ ],
+ "description": "straight, elbowed or curved (default curved)."
+ },
+ "captions": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Labels on the line, up to 20: [{\"content\": \"yes\", \"position\": \"50%\"}]."
+ },
+ "style": {
+ "type": "object",
+ "description": "Line look, values as strings: {\"strokeColor\": \"#1a1a1a\", \"strokeWidth\": \"2\", \"strokeStyle\": \"normal\", \"startStrokeCap\": \"none\", \"endStrokeCap\": \"arrow\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\"}. strokeStyle normal, dotted or dashed; caps none, arrow, stealth, triangle, filled_triangle, diamond, oval and more."
+ }
+ },
+ "required": [
+ "board_id",
+ "start_item",
+ "end_item"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/connectors",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "startItem": "$start_item",
+ "endItem": "$end_item",
+ "shape": "$shape",
+ "captions": "$captions",
+ "style": "$style"
+ }
+ }
+ },
+ {
+ "name": "miro_update_connector",
+ "description": "Change a connector line: its ends, shape, captions or style. Only the fields given change.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "connector_id": {
+ "type": "string",
+ "description": "Connector id."
+ },
+ "start_item": {
+ "type": "object",
+ "description": "New start: {\"id\": \"
- \", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"
- \", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected."
+ },
+ "end_item": {
+ "type": "object",
+ "description": "New end: {\"id\": \"
- \", \"snapTo\": \"auto\"} (snapTo auto, top, right, bottom or left) or {\"id\": \"
- \", \"position\": {\"x\": \"50%\", \"y\": \"0%\"}}. Frames cannot be connected."
+ },
+ "shape": {
+ "type": "string",
+ "enum": [
+ "straight",
+ "elbowed",
+ "curved"
+ ],
+ "description": "straight, elbowed or curved."
+ },
+ "captions": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ },
+ "description": "Labels on the line, up to 20: [{\"content\": \"yes\", \"position\": \"50%\"}]."
+ },
+ "style": {
+ "type": "object",
+ "description": "Line look, values as strings: {\"strokeColor\": \"#1a1a1a\", \"strokeWidth\": \"2\", \"strokeStyle\": \"normal\", \"startStrokeCap\": \"none\", \"endStrokeCap\": \"arrow\", \"color\": \"#1a1a1a\", \"fontSize\": \"14\"}. strokeStyle normal, dotted or dashed; caps none, arrow, stealth, triangle, filled_triangle, diamond, oval and more."
+ }
+ },
+ "required": [
+ "board_id",
+ "connector_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/connectors/{connector_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "startItem": "$start_item",
+ "endItem": "$end_item",
+ "shape": "$shape",
+ "captions": "$captions",
+ "style": "$style"
+ }
+ }
+ },
+ {
+ "name": "miro_delete_connector",
+ "description": "Delete a connector line between two items; the items it joined stay where they are.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "connector_id": {
+ "type": "string",
+ "description": "Connector id."
+ }
+ },
+ "required": [
+ "board_id",
+ "connector_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/boards/{board_id}/connectors/{connector_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_list_board_members",
+ "description": "List who has access to a board: member id (the user id), name, role (viewer, commenter, editor, coowner, owner). Pages by offset.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "description": "Members per page, 1 to 50 (default 20)."
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "How many to skip."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/members",
+ "encodePathParams": true,
+ "queryParams": {
+ "limit": "$limit",
+ "offset": "$offset"
+ }
+ }
+ },
+ {
+ "name": "miro_get_board_member",
+ "description": "Get one member of a board: user id, name and role (viewer, commenter, editor, coowner, owner).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "board_member_id": {
+ "type": "string",
+ "description": "Member id, from miro_list_board_members."
+ }
+ },
+ "required": [
+ "board_id",
+ "board_member_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/members/{board_member_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_share_board",
+ "enabled": false,
+ "description": "Invite people to a board by email with a role; Miro emails them an invitation. Returns successful and failed invitations. Switched off at install.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "emails": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Email addresses, up to 20."
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "viewer",
+ "commenter",
+ "editor",
+ "coowner"
+ ],
+ "description": "viewer, commenter (default), editor or coowner."
+ },
+ "message": {
+ "type": "string",
+ "description": "Personal message for the invitation email."
+ }
+ },
+ "required": [
+ "board_id",
+ "emails"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/members",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "emails": "$emails",
+ "role": "$role",
+ "message": "$message"
+ }
+ }
+ },
+ {
+ "name": "miro_update_board_member",
+ "enabled": false,
+ "description": "Change a board member's role, e.g. from viewer to editor. Switched off at install.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "board_member_id": {
+ "type": "string",
+ "description": "Member id."
+ },
+ "role": {
+ "type": "string",
+ "enum": [
+ "viewer",
+ "commenter",
+ "editor",
+ "coowner"
+ ],
+ "description": "New role."
+ }
+ },
+ "required": [
+ "board_id",
+ "board_member_id",
+ "role"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/members/{board_member_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "role": "$role"
+ }
+ }
+ },
+ {
+ "name": "miro_remove_board_member",
+ "enabled": false,
+ "description": "Remove someone's access to a board. Switched off at install.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "board_member_id": {
+ "type": "string",
+ "description": "Member id."
+ }
+ },
+ "required": [
+ "board_id",
+ "board_member_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/boards/{board_id}/members/{board_member_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_list_tags",
+ "description": "List the tags defined on a board: id, title and fillColor. Pages by offset.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "description": "Tags per page, 1 to 50 (default 20)."
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "How many to skip."
+ }
+ },
+ "required": [
+ "board_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/tags",
+ "encodePathParams": true,
+ "queryParams": {
+ "limit": "$limit",
+ "offset": "$offset"
+ }
+ }
+ },
+ {
+ "name": "miro_get_tag",
+ "description": "Get one tag of a board by its id: title and fillColor, as shown on cards and sticky notes.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "tag_id": {
+ "type": "string",
+ "description": "Tag id, a long number as a string, from miro_list_tags."
+ }
+ },
+ "required": [
+ "board_id",
+ "tag_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/tags/{tag_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_create_tag",
+ "description": "Create a tag on a board, to attach to cards and sticky notes. Titles are unique per board and case-sensitive. Returns the tag with its id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "title": {
+ "type": "string",
+ "description": "Tag text, up to 120 characters."
+ },
+ "fill_color": {
+ "type": "string",
+ "enum": [
+ "red",
+ "light_green",
+ "cyan",
+ "yellow",
+ "magenta",
+ "green",
+ "blue",
+ "gray",
+ "violet",
+ "dark_green",
+ "dark_blue",
+ "black"
+ ],
+ "description": "Color (default red)."
+ }
+ },
+ "required": [
+ "board_id",
+ "title"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/tags",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "title": "$title",
+ "fillColor": "$fill_color"
+ }
+ }
+ },
+ {
+ "name": "miro_update_tag",
+ "description": "Rename a tag or change its color; every item carrying it changes too. The board shows API tag changes after a refresh.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "tag_id": {
+ "type": "string",
+ "description": "Tag id, a long number as a string, from miro_list_tags."
+ },
+ "title": {
+ "type": "string",
+ "description": "New text."
+ },
+ "fill_color": {
+ "type": "string",
+ "enum": [
+ "red",
+ "light_green",
+ "cyan",
+ "yellow",
+ "magenta",
+ "green",
+ "blue",
+ "gray",
+ "violet",
+ "dark_green",
+ "dark_blue",
+ "black"
+ ],
+ "description": "New color."
+ }
+ },
+ "required": [
+ "board_id",
+ "tag_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PATCH",
+ "path": "/boards/{board_id}/tags/{tag_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "title": "$title",
+ "fillColor": "$fill_color"
+ }
+ }
+ },
+ {
+ "name": "miro_delete_tag",
+ "description": "Delete a tag from a board; it is removed from every item that carries it.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "tag_id": {
+ "type": "string",
+ "description": "Tag id, a long number as a string, from miro_list_tags."
+ }
+ },
+ "required": [
+ "board_id",
+ "tag_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/boards/{board_id}/tags/{tag_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_attach_tag",
+ "description": "Attach an existing tag to a card or sticky note (other item types cannot carry tags; at most 8 per item). The board shows it after a refresh.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Id of a card or sticky note."
+ },
+ "tag_id": {
+ "type": "string",
+ "description": "Tag id, a long number as a string, from miro_list_tags."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id",
+ "tag_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/boards/{board_id}/items/{item_id}?tag_id={tag_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_remove_tag_from_item",
+ "description": "Take a tag off a card or sticky note; the tag itself stays on the board. Always pass tag_id: this is the same address as deleting the item.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Id of the card or sticky note."
+ },
+ "tag_id": {
+ "type": "string",
+ "description": "Tag id, a long number as a string, from miro_list_tags."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id",
+ "tag_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/boards/{board_id}/items/{item_id}?tag_id={tag_id}",
+ "encodePathParams": true
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": false,
+ "idempotentHint": true
+ }
+ },
+ {
+ "name": "miro_get_item_tags",
+ "description": "List the tags attached to one card or sticky note, each with id, title and fillColor.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "item_id": {
+ "type": "string",
+ "description": "Id of the card or sticky note."
+ }
+ },
+ "required": [
+ "board_id",
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/items/{item_id}/tags",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "miro_list_items_by_tag",
+ "description": "List the cards and sticky notes that carry a tag. Pages by offset.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "board_id": {
+ "type": "string",
+ "description": "Board id, e.g. uXjVOD6LSME= (the part after /board/ in the board's URL, = included)."
+ },
+ "tag_id": {
+ "type": "string",
+ "description": "Tag id, a long number as a string, from miro_list_tags."
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "description": "Items per page, 1 to 50 (default 20)."
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "How many to skip."
+ }
+ },
+ "required": [
+ "board_id",
+ "tag_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/boards/{board_id}/items",
+ "encodePathParams": true,
+ "queryParams": {
+ "tag_id": "$tag_id",
+ "limit": "$limit",
+ "offset": "$offset"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/miro.live.spec.ts b/packages/backend/src/adapters/intl/miro.live.spec.ts
new file mode 100644
index 000000000..b7201653c
--- /dev/null
+++ b/packages/backend/src/adapters/intl/miro.live.spec.ts
@@ -0,0 +1,307 @@
+import * as http from 'node:http';
+import { AddressInfo } from 'node:net';
+import * as adapterJson from './miro.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Static checks and request-shape checks against a local server always run.
+ * The live block calls Miro with an OAuth access token of an app that has
+ * boards:read and boards:write (the app page's "Install app and get OAuth
+ * token" button gives one):
+ * MIRO_ACCESS_TOKEN=eyJ... [MIRO_BOARD_ID=uXjV...=] npx jest src/adapters/intl/miro.live.spec.ts
+ * Reads use MIRO_BOARD_ID, or the first board the user can open.
+ * MIRO_LIVE_WRITE=1 creates a board "AnythingMCP test" and on it a frame, a
+ * sticky note, a shape, a text, a card, a connector and a tag; reads, changes,
+ * tags, untags and deletes them, and deletes the board in `finally`.
+ */
+
+type Mapping = { method: string; path: string; encodePathParams?: boolean; queryParams?: Record
; bodyMapping?: Record };
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ annotations?: Record;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: Mapping;
+};
+const a = adapterJson as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ appRegistrationUrl: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string };
+ connector: { baseUrl: string; authType: string; authConfig: Record; headers: Record; healthcheckPath: string };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService);
+const OFF = ['miro_delete_board', 'miro_remove_board_member', 'miro_share_board', 'miro_update_board_member'];
+
+describe('miro adapter: static spec conformance', () => {
+ it('is listed (verified live on 11 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ });
+
+ it('signs in through Miro OAuth with the secret in the token request body', () => {
+ expect(a.connector.baseUrl).toBe('https://api.miro.com/v2');
+ expect(a.connector.authType).toBe('OAUTH2');
+ // Scopes are ticked on the app, not requested; Basic client auth makes Miro answer 500.
+ expect(a.connector.authConfig).toEqual({
+ clientId: '{{MIRO_CLIENT_ID}}',
+ clientSecret: '{{MIRO_CLIENT_SECRET}}',
+ authorizationUrl: 'https://miro.com/oauth/authorize',
+ tokenUrl: 'https://api.miro.com/v1/oauth/token',
+ });
+ expect(a.requiredEnvVars).toEqual(['MIRO_CLIENT_ID', 'MIRO_CLIENT_SECRET']);
+ expect(a.envVarMeta.MIRO_CLIENT_SECRET.secret).toBe(true);
+ expect(new RegExp(a.envVarMeta.MIRO_CLIENT_ID.pattern!).test('3458764512345678901')).toBe(true);
+ expect(a.appRegistrationUrl).toBe('https://miro.com/app/settings/user-profile/apps');
+ expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback');
+ expect(a.instructions).toContain('Expire user authorization token');
+ expect(a.instructions).toMatch(/boards:read.*boards:write/);
+ });
+
+ it('probes with the token info, which lives under v1', () => {
+ expect(a.probe.tool).toBe('miro_get_token_info');
+ expect(tool('miro_get_token_info').endpointMapping).toEqual({ method: 'GET', path: 'https://api.miro.com/v1/oauth-token' });
+ expect(a.connector.healthcheckPath).toBe('/boards?limit=1');
+ });
+
+ it('prefixes every tool with miro_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^miro_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('marks every GET read-only and installs board deletion and member management switched off', () => {
+ for (const t of a.tools) expect(`${t.name}:${annotationsOf(t).readOnlyHint === true}`).toBe(`${t.name}:${t.endpointMapping.method === 'GET'}`);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual(OFF);
+ for (const name of ['miro_delete_board', 'miro_delete_item', 'miro_delete_tag', 'miro_delete_connector']) {
+ expect(annotationsOf(tool(name)).destructiveHint).toBe(true);
+ }
+ expect(annotationsOf(tool('miro_copy_board')).destructiveHint).toBe(false);
+ expect(annotationsOf(tool('miro_remove_tag_from_item')).destructiveHint).toBe(false);
+ });
+
+ it('keeps every id a string, since Miro ids exceed the safe integer range', () => {
+ for (const t of a.tools) {
+ for (const [name, p] of Object.entries(t.parameters.properties ?? {})) {
+ if (/_id$/.test(name)) expect(`${t.name}.${name}:${p.type}`).toBe(`${t.name}.${name}:string`);
+ }
+ }
+ });
+
+ it('never lets a tag removal turn into an item deletion', () => {
+ // Removing a tag is DELETE /items/{id}?tag_id=…, the item delete with a
+ // query. The tag id sits in the path template, so a missing one leaves a
+ // placeholder Miro rejects instead of a bare item URL.
+ expect(tool('miro_remove_tag_from_item').endpointMapping.path).toBe('/boards/{board_id}/items/{item_id}?tag_id={tag_id}');
+ expect(tool('miro_remove_tag_from_item').parameters.required).toEqual(['board_id', 'item_id', 'tag_id']);
+ expect(tool('miro_attach_tag').endpointMapping.path).toBe('/boards/{board_id}/items/{item_id}?tag_id={tag_id}');
+ });
+
+ it('only points the model at tools that exist, documents setup and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bmiro_[a-z_]+\*?/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bmiro_[a-z_]+/g)]),
+ ]
+ .map((m) => m[0])
+ // `miro_create_*` stands for a group of tools.
+ .filter((n) => !n.endsWith('*'));
+ expect(mentioned.length).toBeGreaterThan(10);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(a.instructions).toContain('Read-only setup');
+ expect(JSON.stringify(adapterJson)).not.toMatch(/[–—]/);
+ });
+});
+
+describe('miro adapter: requests through the real engine', () => {
+ let server: http.Server;
+ let origin: string;
+ const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body });
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({ id: '3458764517517819000', type: 'sticky_note' }));
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+ afterAll(() => new Promise((r) => server.close(() => r())));
+ beforeEach(() => (seen.length = 0));
+
+ const run = (name: string, params: Record) =>
+ engine().execute(
+ { baseUrl: `${origin}/v2`, authType: 'BEARER_TOKEN', authConfig: { token: 'miro-test' }, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ );
+
+ it('encodes the board id and builds a sticky note body from the given parts only', async () => {
+ await run('miro_create_sticky_note', { board_id: 'uXjVOD6LSME=', content: 'Idea', style: { fillColor: 'yellow' }, position: { x: 100, y: -50 } });
+ expect(seen[0].method).toBe('POST');
+ expect(seen[0].url).toBe('/v2/boards/uXjVOD6LSME%3D/sticky_notes');
+ expect(seen[0].headers.authorization).toBe('Bearer miro-test');
+ expect(JSON.parse(seen[0].body)).toEqual({ data: { content: 'Idea' }, style: { fillColor: 'yellow' }, position: { x: 100, y: -50 } });
+ });
+
+ it('creates a rectangle shape by default and a frame as custom freeform', async () => {
+ await run('miro_create_shape', { board_id: 'b', content: 'Step 1' });
+ expect(JSON.parse(seen[0].body)).toEqual({ data: { shape: 'rectangle', content: 'Step 1' } });
+ await run('miro_create_frame', { board_id: 'b', title: 'Sprint', geometry: { width: 1200, height: 800 } });
+ expect(JSON.parse(seen[1].body)).toEqual({ data: { title: 'Sprint', format: 'custom', type: 'freeform' }, geometry: { width: 1200, height: 800 } });
+ });
+
+ it('maps card fields to Miro names', async () => {
+ await run('miro_create_card', { board_id: 'b', title: 'Fix login', assignee_id: '3074457350000000000', due_date: '2026-10-31T17:00:00Z', parent: { id: '345' } });
+ expect(JSON.parse(seen[0].body)).toEqual({ data: { title: 'Fix login', assigneeId: '3074457350000000000', dueDate: '2026-10-31T17:00:00Z' }, parent: { id: '345' } });
+ });
+
+ it('connects two items with snapping ends', async () => {
+ await run('miro_create_connector', { board_id: 'b', start_item: { id: '1', snapTo: 'right' }, end_item: { id: '2', snapTo: 'left' }, shape: 'elbowed' });
+ expect(JSON.parse(seen[0].body)).toEqual({ startItem: { id: '1', snapTo: 'right' }, endItem: { id: '2', snapTo: 'left' }, shape: 'elbowed' });
+ });
+
+ it('attaches and removes a tag with tag_id in the query, and copies a board', async () => {
+ await run('miro_attach_tag', { board_id: 'b', item_id: '11', tag_id: '22' });
+ expect(`${seen[0].method} ${seen[0].url}`).toBe('POST /v2/boards/b/items/11?tag_id=22');
+ await run('miro_remove_tag_from_item', { board_id: 'b', item_id: '11', tag_id: '22' });
+ expect(`${seen[1].method} ${seen[1].url}`).toBe('DELETE /v2/boards/b/items/11?tag_id=22');
+ await run('miro_copy_board', { board_id: 'uXjVOD6LSME=', name: 'Copy' });
+ expect(`${seen[2].method} ${seen[2].url}`).toBe('PUT /v2/boards?copy_from=uXjVOD6LSME%3D');
+ expect(JSON.parse(seen[2].body)).toEqual({ name: 'Copy' });
+ });
+});
+
+const TOKEN = process.env.MIRO_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('miro adapter: live calls', () => {
+ const config = () => ({ baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers });
+ const run = (name: string, params: Record = {}): Promise =>
+ engine().execute(config(), tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params));
+
+ let boardId = process.env.MIRO_BOARD_ID;
+
+ it('reads the token info (the probe) and finds boards', async () => {
+ const info = await run('miro_get_token_info');
+ expect(info.scopes).toEqual(expect.arrayContaining(['boards:read']));
+ const boards = await run('miro_list_boards', { limit: 5 });
+ expect(Array.isArray(boards.data)).toBe(true);
+ boardId = boardId ?? boards.data[0]?.id;
+ }, 30000);
+
+ it('reads a board, its items, connectors, members and tags', async () => {
+ if (!boardId) return;
+ expect((await run('miro_get_board', { board_id: boardId })).id).toBe(boardId);
+ const items = await run('miro_list_items', { board_id: boardId, limit: 10 });
+ expect(Array.isArray(items.data)).toBe(true);
+ if (items.data[0]) expect((await run('miro_get_item', { board_id: boardId, item_id: items.data[0].id })).id).toBe(items.data[0].id);
+ const connectors = await run('miro_list_connectors', { board_id: boardId });
+ if (connectors.data[0]) expect((await run('miro_get_connector', { board_id: boardId, connector_id: connectors.data[0].id })).id).toBe(connectors.data[0].id);
+ const members = await run('miro_list_board_members', { board_id: boardId });
+ expect(members.data.length).toBeGreaterThan(0);
+ expect((await run('miro_get_board_member', { board_id: boardId, board_member_id: members.data[0].id })).id).toBe(members.data[0].id);
+ const tags = await run('miro_list_tags', { board_id: boardId });
+ if (tags.data[0]) {
+ expect((await run('miro_get_tag', { board_id: boardId, tag_id: tags.data[0].id })).id).toBe(tags.data[0].id);
+ expect(Array.isArray((await run('miro_list_items_by_tag', { board_id: boardId, tag_id: tags.data[0].id })).data)).toBe(true);
+ }
+ }, 60000);
+
+ (process.env.MIRO_LIVE_WRITE === '1' ? it : it.skip)(
+ 'builds an "AnythingMCP test" board with every item type, changes, tags and deletes them, and deletes the board',
+ async () => {
+ let testBoard: string | undefined;
+ try {
+ const board = await run('miro_create_board', { name: 'AnythingMCP test', description: 'Created by the AnythingMCP live spec; safe to delete.' });
+ testBoard = board.id;
+ const renamed = await run('miro_update_board', { board_id: testBoard, name: 'AnythingMCP test (updated)' });
+ expect(renamed.name).toBe('AnythingMCP test (updated)');
+ const b = testBoard!;
+
+ // Miro answered valid frame requests with 500 "Internal error occurred"
+ // for minutes at a time on 11 Oct 2026 (other item types kept working):
+ // retry a 500 for up to a minute before calling it a failure.
+ const createFrame = async (): Promise => {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ return await run('miro_create_frame', { board_id: b, title: 'AnythingMCP frame', position: { x: 0, y: 0 }, geometry: { width: 1600, height: 900 } });
+ } catch (e) {
+ if ((e as { response?: { status?: number } }).response?.status !== 500 || attempt >= 12) throw e;
+ await new Promise((r) => setTimeout(r, 5000));
+ }
+ }
+ };
+ const frame = await createFrame();
+ const sticky = await run('miro_create_sticky_note', { board_id: b, content: 'AnythingMCP sticky', style: { fillColor: 'light_yellow' }, position: { x: -400, y: 0 } });
+ const shape = await run('miro_create_shape', { board_id: b, content: 'AnythingMCP shape', shape: 'round_rectangle', position: { x: 0, y: 0 }, geometry: { width: 200, height: 100 } });
+ const text = await run('miro_create_text', { board_id: b, content: 'AnythingMCP text', position: { x: 0, y: -300 } });
+ const card = await run('miro_create_card', { board_id: b, title: 'AnythingMCP card', description: 'test', position: { x: 400, y: 0 } });
+ const connector = await run('miro_create_connector', { board_id: b, start_item: { id: sticky.id, snapTo: 'right' }, end_item: { id: shape.id, snapTo: 'left' }, shape: 'straight' });
+
+ expect((await run('miro_update_sticky_note', { board_id: b, item_id: sticky.id, content: 'AnythingMCP sticky (updated)', style: { fillColor: 'light_green' } })).data.content).toContain('updated');
+ expect((await run('miro_update_shape', { board_id: b, item_id: shape.id, style: { fillColor: '#d5f692' } })).style.fillColor).toBe('#d5f692');
+ expect((await run('miro_update_text', { board_id: b, item_id: text.id, content: 'AnythingMCP text (updated)' })).data.content).toContain('updated');
+ expect((await run('miro_update_card', { board_id: b, item_id: card.id, title: 'AnythingMCP card (updated)' })).data.title).toContain('updated');
+ expect((await run('miro_update_frame', { board_id: b, item_id: frame.id, title: 'AnythingMCP frame (updated)' })).data.title).toContain('updated');
+ expect((await run('miro_update_connector', { board_id: b, connector_id: connector.id, shape: 'elbowed' })).shape).toBe('elbowed');
+ const moved = await run('miro_move_item', { board_id: b, item_id: card.id, position: { x: 100, y: 100 }, parent: { id: frame.id } });
+ expect(moved.parent.id).toBe(frame.id);
+
+ const items = await run('miro_list_items', { board_id: b, limit: 50 });
+ expect(items.data.length).toBeGreaterThanOrEqual(5);
+ expect((await run('miro_list_items', { board_id: b, parent_item_id: frame.id })).data.map((i: { id: string }) => i.id)).toContain(card.id);
+ expect((await run('miro_get_item', { board_id: b, item_id: sticky.id })).type).toBe('sticky_note');
+ expect((await run('miro_list_connectors', { board_id: b })).data[0].id).toBe(connector.id);
+ expect((await run('miro_get_connector', { board_id: b, connector_id: connector.id })).startItem.id).toBe(sticky.id);
+
+ const tag = await run('miro_create_tag', { board_id: b, title: 'anythingmcp-test', fill_color: 'cyan' });
+ expect((await run('miro_get_tag', { board_id: b, tag_id: tag.id })).title).toBe('anythingmcp-test');
+ expect((await run('miro_update_tag', { board_id: b, tag_id: tag.id, title: 'anythingmcp-test-2' })).title).toBe('anythingmcp-test-2');
+ await run('miro_attach_tag', { board_id: b, item_id: sticky.id, tag_id: tag.id });
+ expect((await run('miro_get_item_tags', { board_id: b, item_id: sticky.id })).tags.map((t: { id: string }) => t.id)).toContain(tag.id);
+ expect((await run('miro_list_items_by_tag', { board_id: b, tag_id: tag.id })).data.map((i: { id: string }) => i.id)).toContain(sticky.id);
+ await run('miro_remove_tag_from_item', { board_id: b, item_id: sticky.id, tag_id: tag.id });
+ // The note itself must survive the tag removal.
+ expect((await run('miro_get_item', { board_id: b, item_id: sticky.id })).id).toBe(sticky.id);
+ await run('miro_delete_tag', { board_id: b, tag_id: tag.id });
+
+ const members = await run('miro_list_board_members', { board_id: b });
+ expect(members.data.some((m: { role: string }) => m.role === 'owner')).toBe(true);
+
+ await run('miro_delete_connector', { board_id: b, connector_id: connector.id });
+ for (const item of [sticky, shape, text, card, frame]) await run('miro_delete_item', { board_id: b, item_id: item.id });
+ expect((await run('miro_list_items', { board_id: b })).data).toEqual([]);
+ } finally {
+ if (testBoard) await run('miro_delete_board', { board_id: testBoard });
+ }
+ },
+ 180000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/paypal.json b/packages/backend/src/adapters/intl/paypal.json
new file mode 100644
index 000000000..dbdfceed8
--- /dev/null
+++ b/packages/backend/src/adapters/intl/paypal.json
@@ -0,0 +1,1149 @@
+{
+ "slug": "paypal",
+ "name": "PayPal",
+ "description": "Let your AI work with your PayPal business account: create and capture orders, refund payments, create, send and chase invoices, search the transaction history and balances, review disputes, and look up subscription plans, subscriptions and payout batches. Sandbox or live, with your own REST app keys.",
+ "instructions": "## PayPal\nThis connector works with one PayPal business account through the **PayPal REST APIs** (`https://api-m.paypal.com`, or `https://api-m.sandbox.paypal.com` for the sandbox): orders and captures, refunds, invoices (Invoicing v2), the transaction history (Transaction Search), balances, disputes, catalog products, subscription plans and subscriptions, and payout batches. It signs in as your own REST app with its client ID and secret (OAuth2 client credentials); no PayPal user has to click through a consent screen.\n\n**Setup (about 10 minutes)**\n1. Sign in at https://developer.paypal.com with the PayPal business account and open **Apps & Credentials**. The toggle at the top switches between **Sandbox** and **Live**; each has its own apps and keys.\n2. Use the **Default Application** or click **Create App**, type **Merchant**, and (in the sandbox) pick the sandbox business account it should act for.\n3. Open the app and, under its features, tick what the AI should reach: **Invoicing**, **Transaction Search**, **Subscriptions** (also covers catalog products), **Customer Disputes**, **Payouts**. PayPal can take up to 9 hours to grant a newly ticked feature to an app that already issued tokens; until then those calls answer 403 `NOT_AUTHORIZED`.\n4. Copy the **Client ID** and **Secret** into `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET`.\n5. Set `PAYPAL_API_URL` to `https://api-m.sandbox.paypal.com` for sandbox keys or `https://api-m.paypal.com` for live keys. Sandbox keys do not work against the live URL and the other way round (the token request fails with `invalid_client`).\n6. Install. AnythingMCP fetches an access token from `PAYPAL_API_URL/v1/oauth2/token` with the client ID and secret and renews it before it expires.\n\n**Money moves: confirm first.** `paypal_capture_order`, `paypal_refund_capture` and `paypal_cancel_subscription` move or stop real money on a live account, and `paypal_send_invoice`, `paypal_remind_invoice` and `paypal_cancel_invoice` email the customer. Before calling any of them, state exactly what will happen (amount, currency, customer, ids) and wait for the user's explicit yes. Pass `request_id` (any UUID) on captures and refunds so a retry cannot act twice. `paypal_delete_draft_invoice` is installed switched off.\n\n**Orders**: `paypal_create_order` creates an order and returns an `approve` (or `payer-action`) link. The buyer must open it and approve before `paypal_capture_order` can collect the money; capturing an unapproved order fails with 422 `ORDER_NOT_APPROVED`. Amounts are strings with the currency's decimals: `{\"currency_code\": \"EUR\", \"value\": \"19.99\"}`. When an order lists `items`, `amount.breakdown.item_total` must equal their sum. The capture id needed for refunds is in `purchase_units[].payments.captures[].id`.\n\n**Refunds**: `paypal_refund_capture` with no amount refunds the whole capture; pass `amount` for a partial refund. Refunds can only be made on captures, up to the captured amount minus earlier refunds.\n\n**Invoices**: `paypal_create_draft_invoice` creates a draft (nothing is emailed); `paypal_send_invoice` sends it (or, with `send_to_recipient: false`, just makes it payable through its link). `paypal_remind_invoice` works on sent, unpaid invoices (at most 2 reminders a day). `paypal_cancel_invoice` works only on sent invoices; drafts are deleted instead. Invoice numbers are generated if you leave `detail.invoice_number` out.\n\n**Transaction history** (`paypal_search_transactions`): `start_date` and `end_date` in RFC 3339 with seconds (`2026-10-01T00:00:00Z` or `2026-10-01T00:00:00+02:00`), at most 31 days apart; for longer periods call once per month. New transactions show up after up to 3 hours, history goes back 3 years. Amount filters use minor units: `[500 TO 1005]` means 5.00 to 10.05.\n\n**Disputes**: `dispute_state` filters by REQUIRED_ACTION (waiting for you), UNDER_PAYPAL_REVIEW, RESOLVED and so on; date filters reach back 180 days.\n\n**Subscriptions**: plans belong to catalog products (`paypal_list_products`, `paypal_list_plans`). `paypal_list_subscriptions` filters by plan and status. `paypal_list_subscription_transactions` needs `start_time` and `end_time`. `paypal_cancel_subscription` ends a subscription for good (it cannot be reactivated) and needs a `reason`.\n\n**Paging**: invoices and disputes take `page` and `page_size`; products, plans and subscriptions allow at most 20 per page; the transaction history up to 500.\n\n**Common errors**\n- 401 `invalid_client` or `invalid_token`: wrong client ID or secret, or keys from the other environment than `PAYPAL_API_URL`.\n- 403 `NOT_AUTHORIZED` / `PERMISSION_DENIED`: the feature is not ticked on the app (Transaction Search, Invoicing, Disputes, Subscriptions, Payouts), or was ticked less than 9 hours ago.\n- 422 `UNPROCESSABLE_ENTITY`: a business rule, named in `details[].issue` (ORDER_NOT_APPROVED, AMOUNT_MISMATCH, CAPTURE_FULLY_REFUNDED, ...).\n- 429 `RATE_LIMIT_REACHED`: wait a little and retry.\n\n**Out of scope here**: card payments and vaulting, webhooks, sending payouts, accepting or answering disputes, editing plans.",
+ "region": "intl",
+ "category": "payments",
+ "icon": "paypal",
+ "docsUrl": "https://developer.paypal.com/api/rest/",
+ "requiredEnvVars": [
+ "PAYPAL_CLIENT_ID",
+ "PAYPAL_CLIENT_SECRET",
+ "PAYPAL_API_URL"
+ ],
+ "appRegistrationUrl": "https://developer.paypal.com/dashboard/applications",
+ "envVarMeta": {
+ "PAYPAL_CLIENT_ID": {
+ "label": "Client ID",
+ "kind": "credential",
+ "help": "developer.paypal.com → Apps & Credentials (Sandbox or Live toggle) → your app → Client ID.",
+ "link": "https://developer.paypal.com/dashboard/applications",
+ "pattern": "^\\S+$",
+ "patternMessage": "Paste the client ID without spaces."
+ },
+ "PAYPAL_CLIENT_SECRET": {
+ "label": "Secret",
+ "kind": "credential",
+ "secret": true,
+ "help": "Same app page: Secret key 1 (click to show), from the same Sandbox or Live section as the client ID.",
+ "link": "https://developer.paypal.com/dashboard/applications",
+ "pattern": "^\\S+$",
+ "patternMessage": "Paste the secret without spaces."
+ },
+ "PAYPAL_API_URL": {
+ "label": "Environment",
+ "kind": "address",
+ "help": "https://api-m.sandbox.paypal.com for sandbox keys, https://api-m.paypal.com for live keys.",
+ "example": "https://api-m.sandbox.paypal.com",
+ "link": "https://developer.paypal.com/api/rest/",
+ "pattern": "^https://api-m\\.(sandbox\\.)?paypal\\.com$",
+ "patternMessage": "Use https://api-m.paypal.com or https://api-m.sandbox.paypal.com, without a trailing slash."
+ }
+ },
+ "verifyHints": {
+ "401": "PayPal refused the client ID or secret. Check that both come from the same app and that PAYPAL_API_URL matches it (sandbox keys with the sandbox URL, live keys with the live URL).",
+ "403": "The app lacks the feature this call needs. On developer.paypal.com open the app and tick Invoicing, Transaction Search, Subscriptions, Customer Disputes or Payouts; a new feature can take up to 9 hours to reach the app.",
+ "rate_limited": "PayPal is rate limiting this app. Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "paypal_list_products",
+ "params": {
+ "page_size": 1
+ }
+ },
+ "prerequisites": "A PayPal business account (or a free developer account with sandbox accounts) and a REST app on developer.paypal.com with the features you need ticked. About 10 minutes.",
+ "connector": {
+ "name": "PayPal REST API",
+ "type": "REST",
+ "baseUrl": "{{PAYPAL_API_URL}}",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "grant": "client_credentials",
+ "clientId": "{{PAYPAL_CLIENT_ID}}",
+ "clientSecret": "{{PAYPAL_CLIENT_SECRET}}",
+ "tokenUrl": "{{PAYPAL_API_URL}}/v1/oauth2/token",
+ "tokenAuthMethod": "client_secret_basic"
+ },
+ "headers": {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/v1/catalogs/products?page_size=1"
+ },
+ "tools": [
+ {
+ "name": "paypal_create_order",
+ "description": "Create a checkout order (nothing is charged yet). Returns the order id, status CREATED or PAYER_ACTION_REQUIRED, and links: the buyer must open the \"approve\" or \"payer-action\" link and approve before paypal_capture_order can collect the money.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "intent": {
+ "type": "string",
+ "description": "CAPTURE to collect the money right after approval, AUTHORIZE to only hold it.",
+ "enum": [
+ "CAPTURE",
+ "AUTHORIZE"
+ ],
+ "default": "CAPTURE"
+ },
+ "purchase_units": {
+ "type": "array",
+ "description": "1 to 10 purchase units. Minimal: [{\"amount\": {\"currency_code\": \"EUR\", \"value\": \"19.99\"}, \"description\": \"Order 1042\"}]. Optional per unit: reference_id, invoice_id, custom_id, items (each name, quantity as string, unit_amount; then amount.breakdown.item_total is required), shipping.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "payment_source": {
+ "type": "object",
+ "description": "Optional payer experience, e.g. {\"paypal\": {\"experience_context\": {\"return_url\": \"https://shop.example/ok\", \"cancel_url\": \"https://shop.example/cancel\", \"user_action\": \"PAY_NOW\", \"shipping_preference\": \"NO_SHIPPING\"}}}."
+ },
+ "request_id": {
+ "type": "string",
+ "description": "Optional idempotency key (PayPal-Request-Id), for example a UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "purchase_units"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/checkout/orders",
+ "headers": {
+ "Prefer": "return=representation",
+ "PayPal-Request-Id": "$request_id"
+ },
+ "bodyMapping": {
+ "intent": "$intent",
+ "purchase_units": "$purchase_units",
+ "payment_source": "$payment_source"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_order",
+ "description": "Read one checkout order: status (CREATED, APPROVED, COMPLETED, VOIDED, ...), amounts, payer, and for completed orders the captures under purchase_units[].payments.captures[].",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "string",
+ "description": "The order id, e.g. \"5O190127TN364715T\"."
+ }
+ },
+ "required": [
+ "order_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/checkout/orders/{order_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_capture_order",
+ "description": "Collect the money of an order the buyer has approved (intent CAPTURE). This charges the buyer: confirm the order id, amount and currency with the user first. Fails with 422 ORDER_NOT_APPROVED if the buyer has not approved yet. Returns the completed order with the capture id under purchase_units[].payments.captures[].id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "string",
+ "description": "The approved order id."
+ },
+ "request_id": {
+ "type": "string",
+ "description": "Optional idempotency key (PayPal-Request-Id), for example a UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "order_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/checkout/orders/{order_id}/capture",
+ "encodePathParams": true,
+ "headers": {
+ "Prefer": "return=representation",
+ "PayPal-Request-Id": "$request_id"
+ },
+ "bodyMapping": {}
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "paypal_get_capture",
+ "description": "Read one captured payment: amount, status (COMPLETED, PENDING, PARTIALLY_REFUNDED, REFUNDED, DECLINED), seller fees and net amount, and links to its order and refunds.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "capture_id": {
+ "type": "string",
+ "description": "The capture id (a transaction id), e.g. \"2GG279541U471931P\"."
+ }
+ },
+ "required": [
+ "capture_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/payments/captures/{capture_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_refund_capture",
+ "description": "Refund a captured payment, in full (no amount) or in part. This sends money back to the buyer and cannot be undone: confirm the capture id, amount and currency with the user first. Returns the refund id and status (COMPLETED or PENDING).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "capture_id": {
+ "type": "string",
+ "description": "The capture id to refund."
+ },
+ "amount": {
+ "type": "object",
+ "description": "For a partial refund, e.g. {\"currency_code\": \"EUR\", \"value\": \"5.00\"}. Leave out to refund what remains of the capture."
+ },
+ "note_to_payer": {
+ "type": "string",
+ "description": "Reason shown to the buyer, up to 255 characters."
+ },
+ "invoice_id": {
+ "type": "string",
+ "description": "Your own invoice or reference number for this refund, up to 127 characters."
+ },
+ "request_id": {
+ "type": "string",
+ "description": "Optional idempotency key (PayPal-Request-Id), for example a UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "capture_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/payments/captures/{capture_id}/refund",
+ "encodePathParams": true,
+ "headers": {
+ "Prefer": "return=representation",
+ "PayPal-Request-Id": "$request_id"
+ },
+ "bodyMapping": {
+ "amount": "$amount",
+ "note_to_payer": "$note_to_payer",
+ "invoice_id": "$invoice_id"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "paypal_get_refund",
+ "description": "Read one refund: amount, status (COMPLETED, PENDING, FAILED, CANCELLED), note to the payer and the capture it belongs to.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "refund_id": {
+ "type": "string",
+ "description": "The refund id."
+ }
+ },
+ "required": [
+ "refund_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/payments/refunds/{refund_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_get_authorization",
+ "description": "Read one authorized payment (an order created with intent AUTHORIZE): amount, status (CREATED, CAPTURED, PARTIALLY_CAPTURED, VOIDED, DENIED, PENDING) and expiry.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "authorization_id": {
+ "type": "string",
+ "description": "The authorization id."
+ }
+ },
+ "required": [
+ "authorization_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/payments/authorizations/{authorization_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_list_invoices",
+ "description": "List invoices, newest first, with id, number, status, recipient, amount and due date. Returns items, plus total_items and total_pages when total_required is true. For filters (status, recipient, dates, amount) use paypal_search_invoices.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Invoices per page, 1 to 100 (default 20).",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 20
+ },
+ "total_required": {
+ "type": "boolean",
+ "description": "true to also return total_items and total_pages."
+ },
+ "fields": {
+ "type": "string",
+ "description": "Comma-separated extra fields to include, e.g. \"items,payments\"."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/invoicing/invoices",
+ "queryParams": {
+ "page": "$page",
+ "page_size": "$page_size",
+ "total_required": "$total_required",
+ "fields": "$fields"
+ }
+ }
+ },
+ {
+ "name": "paypal_search_invoices",
+ "description": "Search invoices by recipient, number, status, currency, dates or amount (all filters combined with AND). Read-only, although it is a POST. Returns items with total_items and total_pages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "recipient_email": {
+ "type": "string",
+ "description": "Recipient email address."
+ },
+ "recipient_business_name": {
+ "type": "string",
+ "description": "Recipient business name."
+ },
+ "invoice_number": {
+ "type": "string",
+ "description": "Exact invoice number."
+ },
+ "status": {
+ "type": "array",
+ "description": "Up to 5 statuses, e.g. [\"SENT\", \"UNPAID\", \"PARTIALLY_PAID\"]. Others: DRAFT, SCHEDULED, PAID, MARKED_AS_PAID, CANCELLED, REFUNDED, PARTIALLY_REFUNDED, PAYMENT_PENDING.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "reference": {
+ "type": "string",
+ "description": "The invoice reference field."
+ },
+ "currency_code": {
+ "type": "string",
+ "description": "Three-letter currency code, e.g. \"EUR\"."
+ },
+ "invoice_date_range": {
+ "type": "object",
+ "description": "Invoice date range, e.g. {\"start\": \"2026-09-01\", \"end\": \"2026-09-30\"}."
+ },
+ "due_date_range": {
+ "type": "object",
+ "description": "Due date range, e.g. {\"start\": \"2026-10-01\", \"end\": \"2026-10-31\"}."
+ },
+ "creation_date_range": {
+ "type": "object",
+ "description": "Creation time range in RFC 3339, both bounds required, e.g. {\"start\": \"2026-09-01T00:00:00Z\", \"end\": \"2026-09-30T23:59:59Z\"}."
+ },
+ "total_amount_range": {
+ "type": "object",
+ "description": "Amount range, both bounds required, e.g. {\"lower_amount\": {\"currency_code\": \"EUR\", \"value\": \"100.00\"}, \"upper_amount\": {\"currency_code\": \"EUR\", \"value\": \"500.00\"}}."
+ },
+ "archived": {
+ "type": "boolean",
+ "description": "true for archived invoices only, false for not archived."
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Invoices per page, 1 to 100 (default 20).",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 20
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoicing/search-invoices",
+ "queryParams": {
+ "page": "$page",
+ "page_size": "$page_size",
+ "total_required": "true"
+ },
+ "bodyMapping": {
+ "recipient_email": "$recipient_email",
+ "recipient_business_name": "$recipient_business_name",
+ "invoice_number": "$invoice_number",
+ "status": "$status",
+ "reference": "$reference",
+ "currency_code": "$currency_code",
+ "invoice_date_range": "$invoice_date_range",
+ "due_date_range": "$due_date_range",
+ "creation_date_range": "$creation_date_range",
+ "total_amount_range": "$total_amount_range",
+ "archived": "$archived"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "paypal_create_draft_invoice",
+ "description": "Create a draft invoice. Nothing is emailed: send it with paypal_send_invoice. Returns the full invoice with its id (INV2-...), number and status DRAFT.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "detail": {
+ "type": "object",
+ "description": "Invoice header. currency_code is required, e.g. {\"currency_code\": \"EUR\", \"invoice_date\": \"2026-10-10\", \"payment_term\": {\"term_type\": \"NET_30\"}, \"note\": \"Thank you\", \"reference\": \"PO-77\"}. Add \"invoice_number\" to set one yourself (unique, up to 25 characters); otherwise PayPal numbers it."
+ },
+ "primary_recipients": {
+ "type": "array",
+ "description": "The customer, e.g. [{\"billing_info\": {\"name\": {\"given_name\": \"Ana\", \"surname\": \"Diaz\"}, \"email_address\": \"ana@example.com\"}}]. Without an email address PayPal cannot email the invoice.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "items": {
+ "type": "array",
+ "description": "Line items, each with name, quantity (string) and unit_amount, e.g. [{\"name\": \"Consulting\", \"quantity\": \"2\", \"unit_amount\": {\"currency_code\": \"EUR\", \"value\": \"150.00\"}, \"tax\": {\"name\": \"VAT\", \"percent\": \"19\"}}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "invoicer": {
+ "type": "object",
+ "description": "Optional seller details, e.g. {\"email_address\": \"billing@yourshop.example\", \"business_name\": \"Your Shop\"}. The email must belong to the PayPal account; the primary one is used if omitted."
+ },
+ "configuration": {
+ "type": "object",
+ "description": "Optional settings, e.g. {\"allow_tip\": false, \"partial_payment\": {\"allow_partial_payment\": true}}."
+ }
+ },
+ "required": [
+ "detail",
+ "items"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoicing/invoices",
+ "headers": {
+ "Prefer": "return=representation"
+ },
+ "bodyMapping": {
+ "detail": "$detail",
+ "invoicer": "$invoicer",
+ "primary_recipients": "$primary_recipients",
+ "items": "$items",
+ "configuration": "$configuration"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_invoice",
+ "description": "Read one invoice: status, recipient, items, amounts, due amount, payments and refunds recorded on it, and the payer link (links[] rel \"payer-view\").",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice id, e.g. \"INV2-Z56S-5LLA-Q52L-CPZ5\"."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/invoicing/invoices/{invoice_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_send_invoice",
+ "description": "Send a draft invoice. By default PayPal emails it to the recipient: confirm with the user first. With send_to_recipient false the invoice becomes payable through its link without an email. Returns a link to the payer view, or 202 when the send is scheduled for a future invoice date.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The draft invoice id."
+ },
+ "send_to_recipient": {
+ "type": "boolean",
+ "description": "Email the invoice to the recipient (default true)."
+ },
+ "send_to_invoicer": {
+ "type": "boolean",
+ "description": "Also email a copy to the seller (default false)."
+ },
+ "additional_recipients": {
+ "type": "array",
+ "description": "Extra email addresses to copy, e.g. [\"accounts@example.com\"].",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoicing/invoices/{invoice_id}/send",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "send_to_recipient": "$send_to_recipient",
+ "send_to_invoicer": "$send_to_invoicer",
+ "additional_recipients": "$additional_recipients"
+ }
+ }
+ },
+ {
+ "name": "paypal_remind_invoice",
+ "description": "Email a payment reminder for a sent, unpaid invoice (PayPal allows 2 reminders a day). Confirm with the user first. Returns no content on success.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The sent invoice id."
+ },
+ "send_to_recipient": {
+ "type": "boolean",
+ "description": "Email the reminder to the recipient (default true)."
+ },
+ "send_to_invoicer": {
+ "type": "boolean",
+ "description": "Also email a copy to the seller (default false)."
+ },
+ "additional_recipients": {
+ "type": "array",
+ "description": "Extra email addresses to copy.",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoicing/invoices/{invoice_id}/remind",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "send_to_recipient": "$send_to_recipient",
+ "send_to_invoicer": "$send_to_invoicer",
+ "additional_recipients": "$additional_recipients"
+ }
+ }
+ },
+ {
+ "name": "paypal_cancel_invoice",
+ "description": "Cancel a sent invoice so it can no longer be paid; by default the recipient is told by email. Drafts cannot be cancelled (they are deleted instead). This cannot be undone: confirm with the user first. Returns no content on success.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The sent invoice id."
+ },
+ "send_to_recipient": {
+ "type": "boolean",
+ "description": "Email the cancellation to the recipient (default true)."
+ },
+ "send_to_invoicer": {
+ "type": "boolean",
+ "description": "Also email a copy to the seller (default false)."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoicing/invoices/{invoice_id}/cancel",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "send_to_recipient": "$send_to_recipient",
+ "send_to_invoicer": "$send_to_invoicer"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "paypal_delete_draft_invoice",
+ "description": "Delete a draft or scheduled invoice for good (sent invoices are cancelled with paypal_cancel_invoice instead). Confirm with the user first. Returns no content on success.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The draft invoice id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/v2/invoicing/invoices/{invoice_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_search_transactions",
+ "description": "Search the account history (Transaction Search): payments received and sent, refunds, fees, transfers and their payer and cart details. At most 31 days per call; data can lag up to 3 hours. Returns transaction_details[] with total_items and total_pages; when nothing matches, PayPal leaves transaction_details out and total_items is 0. Needs Transaction Search enabled on the app.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "start_date": {
+ "type": "string",
+ "description": "Start, RFC 3339 with seconds, e.g. \"2026-09-01T00:00:00Z\" or \"2026-09-01T00:00:00+02:00\"."
+ },
+ "end_date": {
+ "type": "string",
+ "description": "End, same format, at most 31 days after start_date."
+ },
+ "transaction_id": {
+ "type": "string",
+ "description": "Only this transaction id (17 to 19 characters)."
+ },
+ "transaction_type": {
+ "type": "string",
+ "description": "PayPal event code, e.g. \"T0006\" (checkout payment), \"T1107\" (refund), \"T0400\" (withdrawal)."
+ },
+ "transaction_status": {
+ "type": "string",
+ "description": "D denied, P pending, S success, V reversed.",
+ "enum": [
+ "D",
+ "P",
+ "S",
+ "V"
+ ]
+ },
+ "transaction_amount": {
+ "type": "string",
+ "description": "Amount range in minor units, e.g. \"[500 TO 1005]\" for 5.00 to 10.05."
+ },
+ "transaction_currency": {
+ "type": "string",
+ "description": "Three-letter currency code."
+ },
+ "balance_affecting_records_only": {
+ "type": "string",
+ "description": "Y (default) for rows that change the balance only, N for all rows.",
+ "enum": [
+ "Y",
+ "N"
+ ]
+ },
+ "fields": {
+ "type": "string",
+ "description": "Which details to include: \"all\" (default here) or a comma list of transaction_info, payer_info, shipping_info, cart_info, incentive_info, store_info, auction_info.",
+ "default": "all"
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Rows per page, 1 to 500 (default 100).",
+ "minimum": 1,
+ "maximum": 500,
+ "default": 100
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ }
+ },
+ "required": [
+ "start_date",
+ "end_date"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/reporting/transactions",
+ "queryParams": {
+ "start_date": "$start_date",
+ "end_date": "$end_date",
+ "transaction_id": "$transaction_id",
+ "transaction_type": "$transaction_type",
+ "transaction_status": "$transaction_status",
+ "transaction_amount": "$transaction_amount",
+ "transaction_currency": "$transaction_currency",
+ "balance_affecting_records_only": "$balance_affecting_records_only",
+ "fields": "$fields",
+ "page_size": "$page_size",
+ "page": "$page"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_balances",
+ "description": "Read the account balances per currency (available, withheld and total), now or as of a past time: balances[] with as_of_time. An account that has never held money gets no balances key at all. Refreshed with up to 3 hours delay. Needs Transaction Search enabled on the app.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "as_of_time": {
+ "type": "string",
+ "description": "Optional point in time, RFC 3339, e.g. \"2026-09-30T23:59:59Z\"."
+ },
+ "currency_code": {
+ "type": "string",
+ "description": "Only this currency, e.g. \"EUR\"."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/reporting/balances",
+ "queryParams": {
+ "as_of_time": "$as_of_time",
+ "currency_code": "$currency_code"
+ }
+ }
+ },
+ {
+ "name": "paypal_list_disputes",
+ "description": "List disputes, claims and chargebacks raised against the account (last 180 days), with dispute_id, reason, status, dispute_state, amount and dates.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "dispute_state": {
+ "type": "string",
+ "description": "Comma-separated: OPEN_INQUIRIES, REQUIRED_ACTION, REQUIRED_OTHER_PARTY_ACTION, UNDER_PAYPAL_REVIEW, APPEALABLE, RESOLVED."
+ },
+ "disputed_transaction_id": {
+ "type": "string",
+ "description": "Only disputes about this transaction id."
+ },
+ "create_time_after": {
+ "type": "string",
+ "description": "Created after, RFC 3339."
+ },
+ "create_time_before": {
+ "type": "string",
+ "description": "Created before, RFC 3339."
+ },
+ "update_time_after": {
+ "type": "string",
+ "description": "Updated after, RFC 3339."
+ },
+ "update_time_before": {
+ "type": "string",
+ "description": "Updated before, RFC 3339."
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Disputes per page, 1 to 50 (default 10).",
+ "minimum": 1,
+ "maximum": 50,
+ "default": 10
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, 1 to 50.",
+ "minimum": 1,
+ "maximum": 50,
+ "default": 1
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/customer/disputes",
+ "queryParams": {
+ "dispute_state": "$dispute_state",
+ "disputed_transaction_id": "$disputed_transaction_id",
+ "create_time_after": "$create_time_after",
+ "create_time_before": "$create_time_before",
+ "update_time_after": "$update_time_after",
+ "update_time_before": "$update_time_before",
+ "page_size": "$page_size",
+ "page": "$page"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_dispute",
+ "description": "Read one dispute in full: reason, status, amounts, disputed transactions, messages, offers, evidence and the deadline to respond (seller_response_due_date).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "dispute_id": {
+ "type": "string",
+ "description": "The dispute id, e.g. \"PP-D-27803\"."
+ }
+ },
+ "required": [
+ "dispute_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/customer/disputes/{dispute_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_list_products",
+ "description": "List catalog products (the products subscription plans belong to): id, name, description, type and dates.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "page_size": {
+ "type": "integer",
+ "description": "Products per page, 1 to 20 (default 10).",
+ "minimum": 1,
+ "maximum": 20,
+ "default": 10
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ },
+ "total_required": {
+ "type": "boolean",
+ "description": "true to also return total_items and total_pages."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/catalogs/products",
+ "queryParams": {
+ "page_size": "$page_size",
+ "page": "$page",
+ "total_required": "$total_required"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_product",
+ "description": "Read one catalog product: name, description, type (PHYSICAL, DIGITAL, SERVICE), category, image and home URLs.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "type": "string",
+ "description": "The product id, e.g. \"PROD-XYAB12ABSB7868434\"."
+ }
+ },
+ "required": [
+ "product_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/catalogs/products/{product_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_list_plans",
+ "description": "List subscription plans, optionally for one product: id, name, status (CREATED, ACTIVE, INACTIVE), product_id and dates. Prices are in paypal_get_plan.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "type": "string",
+ "description": "Only plans of this product id."
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Plans per page, 1 to 20 (default 10).",
+ "minimum": 1,
+ "maximum": 20,
+ "default": 10
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ },
+ "total_required": {
+ "type": "boolean",
+ "description": "true to also return total_items and total_pages."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/billing/plans",
+ "queryParams": {
+ "product_id": "$product_id",
+ "page_size": "$page_size",
+ "page": "$page",
+ "total_required": "$total_required"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_plan",
+ "description": "Read one subscription plan: billing cycles with prices and frequency, trial periods, setup fee, taxes and payment failure settings.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "plan_id": {
+ "type": "string",
+ "description": "The plan id, e.g. \"P-5ML4271244454362WXNWU5NQ\"."
+ }
+ },
+ "required": [
+ "plan_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/billing/plans/{plan_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "paypal_list_subscriptions",
+ "description": "List subscriptions, filtered by plan and status: id, status, plan_id, subscriber, start time and next billing time.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "plan_ids": {
+ "type": "string",
+ "description": "Comma-separated plan ids."
+ },
+ "statuses": {
+ "type": "string",
+ "description": "Comma-separated: APPROVAL_PENDING, APPROVED, ACTIVE, SUSPENDED, CANCELLED, EXPIRED."
+ },
+ "created_after": {
+ "type": "string",
+ "description": "Created after, RFC 3339."
+ },
+ "created_before": {
+ "type": "string",
+ "description": "Created before, RFC 3339."
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Subscriptions per page, 1 to 20 (default 10).",
+ "minimum": 1,
+ "maximum": 20,
+ "default": 10
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/billing/subscriptions",
+ "queryParams": {
+ "plan_ids": "$plan_ids",
+ "statuses": "$statuses",
+ "created_after": "$created_after",
+ "created_before": "$created_before",
+ "page_size": "$page_size",
+ "page": "$page"
+ }
+ }
+ },
+ {
+ "name": "paypal_get_subscription",
+ "description": "Read one subscription: status, plan, subscriber (name, email, shipping address), billing info (outstanding balance, last payment, next billing time, failed payments count).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "subscription_id": {
+ "type": "string",
+ "description": "The subscription id, e.g. \"I-BW452GLLEP1G\"."
+ },
+ "fields": {
+ "type": "string",
+ "description": "Extra details: \"last_failed_payment\", \"plan\" or both comma-separated."
+ }
+ },
+ "required": [
+ "subscription_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/billing/subscriptions/{subscription_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "fields": "$fields"
+ }
+ }
+ },
+ {
+ "name": "paypal_list_subscription_transactions",
+ "description": "List the payments of one subscription between two times: id, status, amounts with fees and net, payer and time.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "subscription_id": {
+ "type": "string",
+ "description": "The subscription id."
+ },
+ "start_time": {
+ "type": "string",
+ "description": "Start, RFC 3339, e.g. \"2026-01-01T00:00:00Z\"."
+ },
+ "end_time": {
+ "type": "string",
+ "description": "End, RFC 3339."
+ }
+ },
+ "required": [
+ "subscription_id",
+ "start_time",
+ "end_time"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/billing/subscriptions/{subscription_id}/transactions",
+ "encodePathParams": true,
+ "queryParams": {
+ "start_time": "$start_time",
+ "end_time": "$end_time"
+ }
+ }
+ },
+ {
+ "name": "paypal_cancel_subscription",
+ "description": "Cancel a subscription for good: no further payments are collected and it cannot be reactivated. Confirm the subscription and the reason with the user first. Returns no content on success.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "subscription_id": {
+ "type": "string",
+ "description": "The subscription id."
+ },
+ "reason": {
+ "type": "string",
+ "description": "Why it is cancelled, 1 to 128 characters (shown in the subscription history)."
+ }
+ },
+ "required": [
+ "subscription_id",
+ "reason"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/billing/subscriptions/{subscription_id}/cancel",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "reason": "$reason"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "paypal_get_payout_batch",
+ "description": "Read one payout batch: batch status and totals, and its items (receiver, amount, status, fees, errors). Sending payouts is not part of this connector.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "payout_batch_id": {
+ "type": "string",
+ "description": "The payout batch id."
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1,
+ "default": 1
+ },
+ "page_size": {
+ "type": "integer",
+ "description": "Items per page, 1 to 1000.",
+ "minimum": 1,
+ "maximum": 1000
+ },
+ "total_required": {
+ "type": "boolean",
+ "description": "true to also return total_items and total_pages."
+ }
+ },
+ "required": [
+ "payout_batch_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/payments/payouts/{payout_batch_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "page": "$page",
+ "page_size": "$page_size",
+ "total_required": "$total_required"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/paypal.live.spec.ts b/packages/backend/src/adapters/intl/paypal.live.spec.ts
new file mode 100644
index 000000000..811807dab
--- /dev/null
+++ b/packages/backend/src/adapters/intl/paypal.live.spec.ts
@@ -0,0 +1,490 @@
+import axios from 'axios';
+import { ConfigService } from '@nestjs/config';
+import * as adapter from './paypal.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { PrismaService } from '../../common/prisma.service';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { interpolateConnectorConfig, interpolateDeep } from '../../common/env-interpolation.util';
+import { describeAdapterEnvVars } from '../env-var-meta';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the PayPal adapter:
+ *
+ * 1. Static: always runs. Pins the client-credentials token request (HTTP
+ * Basic at PAYPAL_API_URL/v1/oauth2/token), the environment variable
+ * patterns, every tool's method and URL, the bodies and headers of the
+ * money-moving calls, and which tools are read-only or destructive.
+ *
+ * 2. Live: skipped unless PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET are set.
+ * Uses the sandbox unless PAYPAL_API_URL says otherwise:
+ *
+ * PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... \
+ * npx jest src/adapters/intl/paypal.live.spec.ts
+ *
+ * Reads products, plans, subscriptions, invoices, the transaction
+ * history, balances and disputes. Optional ids read what cannot be
+ * created without a buyer: PAYPAL_CAPTURE_ID, PAYPAL_AUTHORIZATION_ID,
+ * PAYPAL_PAYOUT_BATCH_ID.
+ *
+ * PAYPAL_LIVE_WRITE=1 (sandbox only) adds: an order that is created,
+ * read and refused at capture (no buyer approved it); a draft invoice
+ * that is read, found by search, sent, reminded and cancelled (deleted
+ * instead if sending failed). With PAYPAL_CAPTURE_ID it also refunds
+ * 0.01 of that capture; with PAYPAL_CANCEL_SUBSCRIPTION_ID it cancels
+ * that sandbox subscription.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios; post: jest.Mock };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string; params?: Record };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const SANDBOX = 'https://api-m.sandbox.paypal.com';
+const ENV = { PAYPAL_API_URL: SANDBOX, PAYPAL_CLIENT_ID: 'client-id', PAYPAL_CLIENT_SECRET: 'client-secret' };
+const newEngine = () =>
+ new RestEngine(
+ { getAccessToken: jest.fn().mockResolvedValue('test-token') } as unknown as OAuth2TokenService,
+ {} as LoginTokenService,
+ );
+// Resolves {{VAR}} the way DynamicMcpTools does before the engine runs.
+const resolved = (name: string, env: Record = ENV) =>
+ interpolateConnectorConfig({ baseUrl: a.connector.baseUrl, headers: a.connector.headers }, tool(name).endpointMapping, env);
+const call = (name: string, params: Record) => {
+ const { config, endpointMapping } = resolved(name);
+ return newEngine().execute(
+ { baseUrl: config.baseUrl, authType: a.connector.authType, authConfig: { ...a.connector.authConfig }, headers: config.headers },
+ endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ );
+};
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('paypal adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 11 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/sandbox/);
+ });
+
+ it('gets its token with the client-credentials grant at the chosen environment', () => {
+ expect(a.connector.authType).toBe('OAUTH2');
+ expect(a.connector.authConfig).toEqual({
+ grant: 'client_credentials',
+ clientId: '{{PAYPAL_CLIENT_ID}}',
+ clientSecret: '{{PAYPAL_CLIENT_SECRET}}',
+ tokenUrl: '{{PAYPAL_API_URL}}/v1/oauth2/token',
+ tokenAuthMethod: 'client_secret_basic',
+ });
+ // No browser sign-in: nothing to authorize.
+ expect(a.connector.authConfig.authorizationUrl).toBeUndefined();
+ expect(a.connector.baseUrl).toBe('{{PAYPAL_API_URL}}');
+ });
+
+ it('sends client id and secret as HTTP Basic and grant_type=client_credentials in the body', async () => {
+ const service = new OAuth2TokenService(
+ {} as PrismaService,
+ { get: () => 'test-encryption-key-32-chars!!!!' } as unknown as ConfigService,
+ );
+ const post = jest.spyOn(mockedAxios, 'post').mockResolvedValue({ data: { access_token: 'A21AA', expires_in: 32400 } });
+ const authConfig = interpolateDeep({ ...a.connector.authConfig }, ENV);
+ await expect(service.getAccessToken(authConfig)).resolves.toBe('A21AA');
+ const [url, body, options] = post.mock.calls[0] as [string, string, { headers: Record }];
+ expect(url).toBe(`${SANDBOX}/v1/oauth2/token`);
+ expect(body).toBe('grant_type=client_credentials');
+ expect(options.headers.Authorization).toBe(`Basic ${Buffer.from('client-id:client-secret').toString('base64')}`);
+ post.mockRestore();
+ });
+
+ it('validates the environment URL and marks the secret as secret', () => {
+ expect(a.requiredEnvVars).toEqual(['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_API_URL']);
+ const env = new RegExp(a.envVarMeta.PAYPAL_API_URL.pattern!);
+ expect(env.test(SANDBOX)).toBe(true);
+ expect(env.test('https://api-m.paypal.com')).toBe(true);
+ expect(env.test('https://api.paypal.com')).toBe(false);
+ expect(env.test(`${SANDBOX}/`)).toBe(false);
+ expect(env.test('https://api-m.paypal.com.evil.example')).toBe(false);
+ const described = describeAdapterEnvVars(a as never);
+ expect(described.find((d) => d.name === 'PAYPAL_API_URL')!.kind).toBe('address');
+ expect(described.find((d) => d.name === 'PAYPAL_CLIENT_SECRET')!.secret).toBe(true);
+ });
+
+ it('probes with a one-product page and health-checks the same endpoint', () => {
+ expect(a.probe).toEqual({ tool: 'paypal_list_products', params: { page_size: 1 } });
+ expect(tool('paypal_list_products').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/v1/catalogs/products?page_size=1');
+ });
+
+ it('prefixes every tool with paypal_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^paypal_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('marks reads read-only, including the POST invoice search', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'paypal_get_authorization',
+ 'paypal_get_balances',
+ 'paypal_get_capture',
+ 'paypal_get_dispute',
+ 'paypal_get_invoice',
+ 'paypal_get_order',
+ 'paypal_get_payout_batch',
+ 'paypal_get_plan',
+ 'paypal_get_product',
+ 'paypal_get_refund',
+ 'paypal_get_subscription',
+ 'paypal_list_disputes',
+ 'paypal_list_invoices',
+ 'paypal_list_plans',
+ 'paypal_list_products',
+ 'paypal_list_subscription_transactions',
+ 'paypal_list_subscriptions',
+ 'paypal_search_invoices',
+ 'paypal_search_transactions',
+ ]);
+ });
+
+ it('marks money-moving and cancelling tools destructive and non-idempotent, and asks for confirmation', () => {
+ for (const name of ['paypal_capture_order', 'paypal_refund_capture', 'paypal_cancel_invoice', 'paypal_cancel_subscription']) {
+ const ann = annotationsOf(tool(name));
+ expect(`${name}:${ann.readOnlyHint}:${ann.destructiveHint}:${ann.idempotentHint}`).toBe(`${name}:false:true:false`);
+ expect(tool(name).description).toMatch(/confirm/i);
+ }
+ expect(annotationsOf(tool('paypal_delete_draft_invoice')).destructiveHint).toBe(true);
+ expect(annotationsOf(tool('paypal_create_order')).destructiveHint).toBe(false);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['paypal_delete_draft_invoice']);
+ expect(a.instructions).toMatch(/Money moves: confirm first/);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bpaypal_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bpaypal_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(10);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ it.each([
+ ['paypal_create_order', { purchase_units: [] }, 'POST', '/v2/checkout/orders'],
+ ['paypal_get_order', { order_id: '5O190127TN364715T' }, 'GET', '/v2/checkout/orders/5O190127TN364715T'],
+ ['paypal_capture_order', { order_id: '5O190127TN364715T' }, 'POST', '/v2/checkout/orders/5O190127TN364715T/capture'],
+ ['paypal_get_capture', { capture_id: '2GG279541U471931P' }, 'GET', '/v2/payments/captures/2GG279541U471931P'],
+ ['paypal_refund_capture', { capture_id: '2GG279541U471931P' }, 'POST', '/v2/payments/captures/2GG279541U471931P/refund'],
+ ['paypal_get_refund', { refund_id: '1JU08902781691411' }, 'GET', '/v2/payments/refunds/1JU08902781691411'],
+ ['paypal_get_authorization', { authorization_id: '0VF52814937998046' }, 'GET', '/v2/payments/authorizations/0VF52814937998046'],
+ ['paypal_list_invoices', {}, 'GET', '/v2/invoicing/invoices'],
+ ['paypal_search_invoices', {}, 'POST', '/v2/invoicing/search-invoices'],
+ ['paypal_create_draft_invoice', { detail: { currency_code: 'EUR' }, items: [] }, 'POST', '/v2/invoicing/invoices'],
+ ['paypal_get_invoice', { invoice_id: 'INV2-Z56S-5LLA-Q52L-CPZ5' }, 'GET', '/v2/invoicing/invoices/INV2-Z56S-5LLA-Q52L-CPZ5'],
+ ['paypal_send_invoice', { invoice_id: 'INV2-A' }, 'POST', '/v2/invoicing/invoices/INV2-A/send'],
+ ['paypal_remind_invoice', { invoice_id: 'INV2-A' }, 'POST', '/v2/invoicing/invoices/INV2-A/remind'],
+ ['paypal_cancel_invoice', { invoice_id: 'INV2-A' }, 'POST', '/v2/invoicing/invoices/INV2-A/cancel'],
+ ['paypal_delete_draft_invoice', { invoice_id: 'INV2-A' }, 'DELETE', '/v2/invoicing/invoices/INV2-A'],
+ ['paypal_search_transactions', { start_date: '2026-09-01T00:00:00Z', end_date: '2026-09-30T23:59:59Z' }, 'GET', '/v1/reporting/transactions'],
+ ['paypal_get_balances', {}, 'GET', '/v1/reporting/balances'],
+ ['paypal_list_disputes', {}, 'GET', '/v1/customer/disputes'],
+ ['paypal_get_dispute', { dispute_id: 'PP-D-27803' }, 'GET', '/v1/customer/disputes/PP-D-27803'],
+ ['paypal_list_products', {}, 'GET', '/v1/catalogs/products'],
+ ['paypal_get_product', { product_id: 'PROD-XYAB12ABSB7868434' }, 'GET', '/v1/catalogs/products/PROD-XYAB12ABSB7868434'],
+ ['paypal_list_plans', {}, 'GET', '/v1/billing/plans'],
+ ['paypal_get_plan', { plan_id: 'P-5ML4271244454362WXNWU5NQ' }, 'GET', '/v1/billing/plans/P-5ML4271244454362WXNWU5NQ'],
+ ['paypal_list_subscriptions', {}, 'GET', '/v1/billing/subscriptions'],
+ ['paypal_get_subscription', { subscription_id: 'I-BW452GLLEP1G' }, 'GET', '/v1/billing/subscriptions/I-BW452GLLEP1G'],
+ ['paypal_list_subscription_transactions', { subscription_id: 'I-BW452GLLEP1G', start_time: '2026-01-01T00:00:00Z', end_time: '2026-10-01T00:00:00Z' }, 'GET', '/v1/billing/subscriptions/I-BW452GLLEP1G/transactions'],
+ ['paypal_cancel_subscription', { subscription_id: 'I-BW452GLLEP1G', reason: 'Customer request' }, 'POST', '/v1/billing/subscriptions/I-BW452GLLEP1G/cancel'],
+ ['paypal_get_payout_batch', { payout_batch_id: 'FYXMPQTX4JC9N' }, 'GET', '/v1/payments/payouts/FYXMPQTX4JC9N'],
+ ])('%s sends %s to the right URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method,
+ url: `${SANDBOX}${path}`,
+ headers: expect.objectContaining({ Authorization: 'Bearer test-token', Accept: 'application/json' }),
+ }),
+ );
+ });
+
+ it('covers every tool in the URL table', () => {
+ expect(a.tools.length).toBe(28);
+ });
+
+ it('creates an order with CAPTURE by default and asks for the full representation', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ const units = [{ amount: { currency_code: 'EUR', value: '19.99' } }];
+ await call('paypal_create_order', { purchase_units: units, request_id: 'req-1' });
+ expect(sent().data).toEqual({ intent: 'CAPTURE', purchase_units: units });
+ expect(sent().headers).toEqual(expect.objectContaining({ Prefer: 'return=representation', 'PayPal-Request-Id': 'req-1' }));
+ });
+
+ it('captures with an empty JSON body and omits the idempotency header when none is given', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('paypal_capture_order', { order_id: 'ORDER1' });
+ expect(sent().data).toEqual({});
+ expect(sent().headers['Content-Type']).toBe('application/json');
+ expect(sent().headers['PayPal-Request-Id']).toBeUndefined();
+ });
+
+ it('refunds a capture in part with a note, or in full with an empty body', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('paypal_refund_capture', { capture_id: 'CAP1', amount: { currency_code: 'EUR', value: '5.00' }, note_to_payer: 'Sorry', request_id: 'r2' });
+ expect(sent().data).toEqual({ amount: { currency_code: 'EUR', value: '5.00' }, note_to_payer: 'Sorry' });
+ expect(sent().headers['PayPal-Request-Id']).toBe('r2');
+ mockedAxios.mockClear();
+ await call('paypal_refund_capture', { capture_id: 'CAP1' });
+ expect(sent().data).toEqual({});
+ });
+
+ it('searches invoices with filters in the body and paging in the query', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('paypal_search_invoices', { status: ['SENT', 'UNPAID'], recipient_email: 'ana@example.com', page: 2 });
+ expect(sent().data).toEqual({ status: ['SENT', 'UNPAID'], recipient_email: 'ana@example.com' });
+ expect(sent().params).toEqual({ page: 2, page_size: 20, total_required: 'true' });
+ });
+
+ it('asks for all transaction details by default', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('paypal_search_transactions', { start_date: '2026-09-01T00:00:00Z', end_date: '2026-09-30T23:59:59Z', transaction_status: 'S' });
+ expect(sent().params).toEqual({
+ start_date: '2026-09-01T00:00:00Z',
+ end_date: '2026-09-30T23:59:59Z',
+ transaction_status: 'S',
+ fields: 'all',
+ page_size: 100,
+ page: 1,
+ });
+ // The offset of a local time must survive as `+`, encoded.
+ expect(sent().paramsSerializer({ start_date: '2026-09-01T00:00:00+02:00' })).toBe('start_date=2026-09-01T00:00:00%2B02:00');
+ });
+
+ it('sends, reminds and cancels invoices with notification flags only', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('paypal_send_invoice', { invoice_id: 'INV2-A', send_to_recipient: false });
+ expect(sent().data).toEqual({ send_to_recipient: false });
+ mockedAxios.mockClear();
+ await call('paypal_cancel_invoice', { invoice_id: 'INV2-A' });
+ expect(sent().data).toEqual({});
+ mockedAxios.mockClear();
+ await call('paypal_cancel_subscription', { subscription_id: 'I-1', reason: 'Asked by customer' });
+ expect(sent().data).toEqual({ reason: 'Asked by customer' });
+ });
+});
+
+const CLIENT_ID = process.env.PAYPAL_CLIENT_ID;
+const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET;
+const live = CLIENT_ID && CLIENT_SECRET ? describe : describe.skip;
+
+live('paypal adapter: live REST API', () => {
+ const host = process.env.PAYPAL_API_URL || SANDBOX;
+ const isSandbox = host === SANDBOX;
+ const write = process.env.PAYPAL_LIVE_WRITE === '1' && isSandbox;
+ let token = '';
+ beforeAll(async () => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ const res = await mockedAxios.__actual.post(`${host}/v1/oauth2/token`, 'grant_type=client_credentials', {
+ auth: { username: CLIENT_ID as string, password: CLIENT_SECRET as string },
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ });
+ token = res.data.access_token;
+ expect(token).toBeTruthy();
+ });
+ const run = (name: string, params: Record = {}): Promise => {
+ const { config, endpointMapping } = resolved(name, { ...ENV, PAYPAL_API_URL: host });
+ return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute(
+ { baseUrl: config.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token }, headers: config.headers } as never,
+ endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ );
+ };
+ const iso = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
+
+ it('reads products, plans and subscriptions', async () => {
+ const products = await run('paypal_list_products', { page_size: 5, total_required: true });
+ expect(Array.isArray(products.products ?? [])).toBe(true);
+ if (products.products?.length) {
+ const one = await run('paypal_get_product', { product_id: products.products[0].id });
+ expect(one.id).toBe(products.products[0].id);
+ }
+ const plans = await run('paypal_list_plans', { page_size: 5 });
+ expect(Array.isArray(plans.plans ?? [])).toBe(true);
+ if (plans.plans?.length) {
+ const plan = await run('paypal_get_plan', { plan_id: plans.plans[0].id });
+ expect(plan.id).toBe(plans.plans[0].id);
+ const subs = await run('paypal_list_subscriptions', { plan_ids: plan.id, page_size: 5 });
+ expect(Array.isArray(subs.subscriptions ?? [])).toBe(true);
+ if (subs.subscriptions?.length) {
+ const sub = await run('paypal_get_subscription', { subscription_id: subs.subscriptions[0].id, fields: 'plan' });
+ expect(sub.id).toBe(subs.subscriptions[0].id);
+ const txs = await run('paypal_list_subscription_transactions', {
+ subscription_id: sub.id,
+ start_time: iso(new Date(Date.now() - 300 * 86400_000)),
+ end_time: iso(new Date()),
+ });
+ expect(txs).toBeDefined();
+ }
+ }
+ }, 60_000);
+
+ it('reads invoices, the transaction history, balances and disputes', async () => {
+ const invoices = await run('paypal_list_invoices', { page_size: 5, total_required: true });
+ expect(typeof invoices.total_items).toBe('number');
+ if (invoices.items?.length) {
+ const one = await run('paypal_get_invoice', { invoice_id: invoices.items[0].id });
+ expect(one.id).toBe(invoices.items[0].id);
+ }
+ const found = await run('paypal_search_invoices', { status: ['DRAFT', 'SENT', 'UNPAID', 'PAID'], page_size: 5 });
+ expect(found).toBeDefined();
+ const history = await run('paypal_search_transactions', {
+ start_date: iso(new Date(Date.now() - 30 * 86400_000)),
+ end_date: iso(new Date()),
+ page_size: 10,
+ });
+ // With no matching transactions PayPal leaves transaction_details out entirely.
+ if (history.total_items === 0) expect(history.transaction_details ?? []).toEqual([]);
+ else expect(Array.isArray(history.transaction_details)).toBe(true);
+ const balances = await run('paypal_get_balances');
+ // A fresh sandbox account that never held money answers without a balances key.
+ expect(balances.as_of_time).toBeDefined();
+ expect(Array.isArray(balances.balances ?? [])).toBe(true);
+ const disputes = await run('paypal_list_disputes', { page_size: 5 });
+ if (disputes.items?.length) {
+ const one = await run('paypal_get_dispute', { dispute_id: disputes.items[0].dispute_id });
+ expect(one.dispute_id).toBe(disputes.items[0].dispute_id);
+ }
+ }, 60_000);
+
+ it('reads a capture, an authorization and a payout batch when their ids are given', async () => {
+ if (process.env.PAYPAL_CAPTURE_ID) {
+ const capture = await run('paypal_get_capture', { capture_id: process.env.PAYPAL_CAPTURE_ID });
+ expect(capture.id).toBe(process.env.PAYPAL_CAPTURE_ID);
+ }
+ if (process.env.PAYPAL_AUTHORIZATION_ID) {
+ const auth = await run('paypal_get_authorization', { authorization_id: process.env.PAYPAL_AUTHORIZATION_ID });
+ expect(auth.id).toBe(process.env.PAYPAL_AUTHORIZATION_ID);
+ }
+ if (process.env.PAYPAL_PAYOUT_BATCH_ID) {
+ const batch = await run('paypal_get_payout_batch', { payout_batch_id: process.env.PAYPAL_PAYOUT_BATCH_ID });
+ expect(batch.batch_header.payout_batch_id).toBe(process.env.PAYPAL_PAYOUT_BATCH_ID);
+ }
+ }, 60_000);
+
+ (write ? it : it.skip)('creates an order, reads it, and is refused at capture without buyer approval', async () => {
+ const order = await run('paypal_create_order', {
+ purchase_units: [{ amount: { currency_code: 'EUR', value: '1.00' }, description: 'AnythingMCP live spec' }],
+ });
+ expect(order.status).toMatch(/CREATED|PAYER_ACTION_REQUIRED/);
+ const read = await run('paypal_get_order', { order_id: order.id });
+ expect(read.id).toBe(order.id);
+ await expect(run('paypal_capture_order', { order_id: order.id, request_id: `amcp-${Date.now()}` })).rejects.toMatchObject({
+ response: { status: 422 },
+ });
+ }, 60_000);
+
+ (write ? it : it.skip)('creates a draft invoice, finds it, sends, reminds and cancels it', async () => {
+ const recipient = process.env.PAYPAL_TEST_BUYER_EMAIL || 'anythingmcp-buyer@example.com';
+ const created = await run('paypal_create_draft_invoice', {
+ detail: { currency_code: 'EUR', note: 'AnythingMCP live spec, safe to ignore', payment_term: { term_type: 'NET_10' } },
+ primary_recipients: [{ billing_info: { email_address: recipient } }],
+ items: [{ name: 'AnythingMCP test line', quantity: '1', unit_amount: { currency_code: 'EUR', value: '1.00' } }],
+ });
+ expect(created.status).toBe('DRAFT');
+ let isSent = false;
+ try {
+ const read = await run('paypal_get_invoice', { invoice_id: created.id });
+ expect(read.detail.invoice_number).toBe(created.detail.invoice_number);
+ const found = await run('paypal_search_invoices', { invoice_number: created.detail.invoice_number });
+ expect(found.items.map((i: { id: string }) => i.id)).toContain(created.id);
+ await run('paypal_send_invoice', { invoice_id: created.id });
+ isSent = true;
+ const afterSend = await run('paypal_get_invoice', { invoice_id: created.id });
+ expect(afterSend.status).toMatch(/SENT|UNPAID/);
+ await run('paypal_remind_invoice', { invoice_id: created.id, send_to_invoicer: false });
+ } finally {
+ if (isSent) {
+ await run('paypal_cancel_invoice', { invoice_id: created.id, send_to_recipient: false });
+ const cancelled = await run('paypal_get_invoice', { invoice_id: created.id });
+ expect(cancelled.status).toBe('CANCELLED');
+ } else {
+ await run('paypal_delete_draft_invoice', { invoice_id: created.id });
+ }
+ }
+ }, 120_000);
+
+ (write && process.env.PAYPAL_CAPTURE_ID ? it : it.skip)('refunds 0.01 of the given sandbox capture and reads the refund', async () => {
+ const capture = await run('paypal_get_capture', { capture_id: process.env.PAYPAL_CAPTURE_ID });
+ const refund = await run('paypal_refund_capture', {
+ capture_id: capture.id,
+ amount: { currency_code: capture.amount.currency_code, value: '0.01' },
+ note_to_payer: 'AnythingMCP live spec',
+ request_id: `amcp-refund-${Date.now()}`,
+ });
+ expect(refund.status).toMatch(/COMPLETED|PENDING/);
+ const read = await run('paypal_get_refund', { refund_id: refund.id });
+ expect(read.id).toBe(refund.id);
+ }, 60_000);
+
+ (write && process.env.PAYPAL_CANCEL_SUBSCRIPTION_ID ? it : it.skip)('cancels the given sandbox subscription', async () => {
+ const id = process.env.PAYPAL_CANCEL_SUBSCRIPTION_ID as string;
+ await run('paypal_cancel_subscription', { subscription_id: id, reason: 'AnythingMCP live spec' });
+ const sub = await run('paypal_get_subscription', { subscription_id: id });
+ expect(sub.status).toBe('CANCELLED');
+ }, 60_000);
+});
diff --git a/packages/backend/src/adapters/intl/shippo.json b/packages/backend/src/adapters/intl/shippo.json
new file mode 100644
index 000000000..a5c5c58d0
--- /dev/null
+++ b/packages/backend/src/adapters/intl/shippo.json
@@ -0,0 +1,1233 @@
+{
+ "slug": "shippo",
+ "name": "Shippo",
+ "description": "Let your AI handle shipping through Shippo: validate addresses, compare live rates from your carriers, buy and refund labels, track parcels, prepare customs paperwork and create scan forms. Test mode is free.",
+ "instructions": "## Shippo\nThis connector works with one Shippo account through the **Shippo API** (`https://api.goshippo.com`): addresses and validation, parcels, shipments with live rates from your carriers, buying labels, tracking, carrier accounts, customs declarations, label refunds and manifests (scan forms).\n\n**Setup (about 3 minutes)**\n1. Sign up at https://apps.goshippo.com/join (free) and open the API portal: https://portal.goshippo.com/api-config/api → **API Configuration → Developer keys**.\n2. Click **Create new test key** (or **Create new live key**) and copy it at once; it is shown only once. Test keys start with `shippo_test_`, live keys with `shippo_live_`.\n3. Paste it into `SHIPPO_API_TOKEN` and install. `shippo_list_carrier_accounts` should list Shippo's own carrier accounts (for example USPS), which are ready to use in the US.\n\n**Test or live**: the key decides. With a test key labels are free, watermarked \"SAMPLE - DO NOT MAIL\", tracking numbers never update, refunds always succeed, and manifests and batches are not available. A test key sees only test data and a live key only live data. Carrier accounts you connect with your own carrier credentials work only in the mode in which they were added.\n\n**Buying a label costs money: confirm first.** With a live key `shippo_buy_label` charges the label price (plus any insurance) to the Shippo account at once. Before calling it, state carrier, service, price and both addresses and wait for the user's yes. `shippo_request_refund` voids a label (it can no longer be used) and asks the carrier for the money back; only unused labels up to 90 days old qualify, and the refund itself takes days to weeks.\n\n**Typical flow**\n1. `shippo_create_shipment` with `address_from`, `address_to` and `parcels` (objects or saved object_ids) returns `rates[]`: each with `object_id`, `amount`, `currency`, `provider`, `servicelevel.name`, `estimated_days`.\n2. Pick a rate with the user, then `shippo_buy_label` with its `object_id`. The answer has `tracking_number`, `tracking_url_provider` and `label_url` (a signed link to the PDF that expires; read the label again with `shippo_get_label` for a fresh one).\n3. Follow the parcel with `shippo_get_tracking` (the carrier token as Shippo names it, such as `usps`, `ups`, `fedex` or `dhl_express`; `shippo_list_carrier_accounts` shows the tokens, plus the tracking number). In test mode use carrier `shippo` with tracking number `SHIPPO_TRANSIT`, `SHIPPO_DELIVERED`, `SHIPPO_RETURNED` or `SHIPPO_FAILURE`.\n\n**International shipments** need a customs declaration: create items with `shippo_create_customs_item` (description, quantity, net weight, value, origin country, tariff number), then `shippo_create_customs_declaration` with those items, `contents_type`, `non_delivery_option`, `certify: true` and `certify_signer`, and pass its object_id as `customs_declaration` to `shippo_create_shipment`.\n\n**Units and formats**: dimensions and weights are strings with their units (`\"length\": \"30\", \"distance_unit\": \"cm\"`, `\"weight\": \"1.2\", \"mass_unit\": \"kg\"`). Countries are ISO 2 codes (`DE`, `US`). Dates are ISO 8601 (`2026-10-12T09:00:00Z`).\n\n**Waiting for results**: the shipment, label, refund and manifest tools wait for the result by default (`async: false`). If Shippo answers with `status` QUEUED or WAITING, read the object again a few seconds later. A label with `status` ERROR explains why in `messages[]`.\n\n**Address validation**: `shippo_create_address` with `validate: true` (or `shippo_validate_address` on a saved address) returns `validation_results.is_valid` and messages. `shippo_validate_address` saves the checked, standardized address as a new object with its own `object_id` and leaves the original untouched, so ship to the new id. With a test key, addresses outside the US are echoed back without being checked; with a live key, validating them is charged.\n\n**API version**: no `SHIPPO-API-VERSION` header is sent, so the account's default version applies. Sending one would also change the version for later calls of the whole account.\n\n**Rate limits** per minute: 500 creates and 4,000 reads of single objects, but only 50 list calls (10 with a test key); prefer reading single objects by id over paging through lists.\n\n**Common errors**\n- 401 `Token does not exist`: wrong or deleted key; create a new one in the API portal.\n- 400: a field is missing or invalid; the body names it (for example an address that the carrier rejects).\n- 422 / `status: ERROR` with `messages[]`: the carrier refused, for example an unsupported service for that route or missing customs data.\n- 429: over the rate limit; wait a minute.\n\n**Out of scope here**: batches, orders, pickups, webhooks other than tracking registration, connecting carrier accounts and the v2 address API.",
+ "region": "intl",
+ "category": "logistics",
+ "icon": "shippo",
+ "docsUrl": "https://docs.goshippo.com/shippoapi/public-api/",
+ "requiredEnvVars": [
+ "SHIPPO_API_TOKEN"
+ ],
+ "appRegistrationUrl": "https://portal.goshippo.com/api-config/api",
+ "envVarMeta": {
+ "SHIPPO_API_TOKEN": {
+ "label": "API token",
+ "kind": "credential",
+ "secret": true,
+ "help": "Shippo API portal → API Configuration → Developer keys → Create new test key (free) or live key. Shown only once.",
+ "link": "https://portal.goshippo.com/api-config/api",
+ "pattern": "^shippo_(test|live)_\\S+$",
+ "patternMessage": "Paste the key as shown, starting with shippo_test_ or shippo_live_, without the word ShippoToken."
+ }
+ },
+ "verifyHints": {
+ "401": "Shippo refused the token. Create a new key in the API portal (API Configuration → Developer keys) and paste it without the word ShippoToken.",
+ "rate_limited": "Shippo is rate limiting this account (list calls are limited to 50 per minute, 10 with a test key). Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "shippo_list_carrier_accounts",
+ "params": {
+ "results": 1
+ }
+ },
+ "prerequisites": "A Shippo account (free) and an API key from the Shippo API portal. A test key costs nothing; a live key buys real labels. About 3 minutes.",
+ "connector": {
+ "name": "Shippo API",
+ "type": "REST",
+ "baseUrl": "https://api.goshippo.com",
+ "authType": "API_KEY",
+ "authConfig": {
+ "headerName": "Authorization",
+ "apiKey": "ShippoToken {{SHIPPO_API_TOKEN}}"
+ },
+ "headers": {
+ "Accept": "application/json",
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/carrier_accounts?results=1"
+ },
+ "tools": [
+ {
+ "name": "shippo_create_address",
+ "description": "Save an address, optionally validated. Returns the address with object_id (usable in shipments instead of the full address), is_complete and, with validate true, validation_results.is_valid and messages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Full name of the person."
+ },
+ "company": {
+ "type": "string",
+ "description": "Company name."
+ },
+ "street1": {
+ "type": "string",
+ "description": "Street and house number."
+ },
+ "street2": {
+ "type": "string",
+ "description": "Second address line."
+ },
+ "street_no": {
+ "type": "string",
+ "description": "House number, if the carrier wants it separate (some EU carriers)."
+ },
+ "city": {
+ "type": "string",
+ "description": "City."
+ },
+ "state": {
+ "type": "string",
+ "description": "State or province code (required for US, CA and some other countries)."
+ },
+ "zip": {
+ "type": "string",
+ "description": "Postal code."
+ },
+ "country": {
+ "type": "string",
+ "description": "ISO 3166 two-letter country code, e.g. \"DE\" or \"US\"."
+ },
+ "phone": {
+ "type": "string",
+ "description": "Phone number (many carriers require it for the recipient)."
+ },
+ "email": {
+ "type": "string",
+ "description": "Email address."
+ },
+ "is_residential": {
+ "type": "boolean",
+ "description": "true for a private home, false for a business."
+ },
+ "validate": {
+ "type": "boolean",
+ "description": "true to validate the address while saving it; the answer then has validation_results."
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note, up to 100 characters."
+ }
+ },
+ "required": [
+ "country"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/addresses",
+ "bodyMapping": {
+ "name": "$name",
+ "company": "$company",
+ "street1": "$street1",
+ "street2": "$street2",
+ "street_no": "$street_no",
+ "city": "$city",
+ "state": "$state",
+ "zip": "$zip",
+ "country": "$country",
+ "phone": "$phone",
+ "email": "$email",
+ "is_residential": "$is_residential",
+ "validate": "$validate",
+ "metadata": "$metadata"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_address",
+ "description": "Read a saved address by object_id, with its fields, completeness and any validation results.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "address_id": {
+ "type": "string",
+ "description": "The address object_id."
+ }
+ },
+ "required": [
+ "address_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/addresses/{address_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_validate_address",
+ "description": "Validate a saved address. Shippo answers with a new saved address that has its own object_id, the standardized fields (for example the ZIP+4 code) and validation_results (is_valid, messages with suggested corrections); the original address stays as it was. Use the new object_id in shipments. With a test key, non-US addresses are not checked.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "address_id": {
+ "type": "string",
+ "description": "The address object_id."
+ }
+ },
+ "required": [
+ "address_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/addresses/{address_id}/validate",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_list_addresses",
+ "description": "List saved addresses, newest first, with next and previous page links.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/addresses",
+ "queryParams": {
+ "page": "$page",
+ "results": "$results"
+ }
+ }
+ },
+ {
+ "name": "shippo_create_parcel",
+ "description": "Save a parcel (dimensions and weight, or a carrier parcel template plus weight). Returns object_id, usable in shipments.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "length": {
+ "type": "string",
+ "description": "Length, e.g. \"30\"."
+ },
+ "width": {
+ "type": "string",
+ "description": "Width."
+ },
+ "height": {
+ "type": "string",
+ "description": "Height."
+ },
+ "distance_unit": {
+ "type": "string",
+ "description": "cm, in, ft, m, mm or yd.",
+ "enum": [
+ "cm",
+ "in",
+ "ft",
+ "m",
+ "mm",
+ "yd"
+ ]
+ },
+ "weight": {
+ "type": "string",
+ "description": "Weight, e.g. \"1.2\"."
+ },
+ "mass_unit": {
+ "type": "string",
+ "description": "g, kg, lb or oz.",
+ "enum": [
+ "g",
+ "kg",
+ "lb",
+ "oz"
+ ]
+ },
+ "template": {
+ "type": "string",
+ "description": "A carrier parcel template token instead of dimensions, e.g. \"USPS_FlatRateEnvelope\" (see shippo_list_parcel_templates)."
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note, up to 100 characters."
+ }
+ },
+ "required": [
+ "weight",
+ "mass_unit"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/parcels",
+ "bodyMapping": {
+ "length": "$length",
+ "width": "$width",
+ "height": "$height",
+ "distance_unit": "$distance_unit",
+ "weight": "$weight",
+ "mass_unit": "$mass_unit",
+ "template": "$template",
+ "metadata": "$metadata"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_parcel",
+ "description": "Read a saved parcel by object_id: dimensions, weight, units and template.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "parcel_id": {
+ "type": "string",
+ "description": "The parcel object_id."
+ }
+ },
+ "required": [
+ "parcel_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/parcels/{parcel_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_list_parcel_templates",
+ "description": "List carrier parcel templates (flat rate boxes and envelopes): token, name, carrier and dimensions.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "carrier": {
+ "type": "string",
+ "description": "Only templates of this carrier, e.g. \"usps\", \"fedex\", \"ups\"."
+ },
+ "include": {
+ "type": "string",
+ "description": "all, user or enabled.",
+ "enum": [
+ "all",
+ "user",
+ "enabled"
+ ]
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/parcel-templates",
+ "queryParams": {
+ "carrier": "$carrier",
+ "include": "$include"
+ }
+ }
+ },
+ {
+ "name": "shippo_create_shipment",
+ "description": "Create a shipment and get live rates from the account's carriers. Nothing is bought. Returns object_id, status and rates[] (object_id, amount, currency, provider, servicelevel, estimated_days, attributes such as CHEAPEST and FASTEST).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "address_from": {
+ "description": "An address object {\"name\", \"company\", \"street1\", \"street2\", \"city\", \"state\", \"zip\", \"country\" (ISO 2), \"phone\", \"email\"} or the object_id of a saved address.",
+ "anyOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "address_to": {
+ "description": "An address object {\"name\", \"company\", \"street1\", \"street2\", \"city\", \"state\", \"zip\", \"country\" (ISO 2), \"phone\", \"email\"} or the object_id of a saved address.",
+ "anyOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "parcels": {
+ "type": "array",
+ "description": "Parcels: objects {\"length\", \"width\", \"height\", \"distance_unit\", \"weight\", \"mass_unit\"} or parcel object_ids.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ },
+ "address_return": {
+ "description": "Optional return address (object or object_id).",
+ "anyOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "customs_declaration": {
+ "type": "string",
+ "description": "object_id of a customs declaration, required for international shipments."
+ },
+ "extra": {
+ "type": "object",
+ "description": "Optional services, e.g. {\"signature_confirmation\": \"STANDARD\", \"insurance\": {\"amount\": \"200\", \"currency\": \"EUR\", \"content\": \"Books\"}, \"reference_1\": \"Order 1042\", \"is_return\": false}."
+ },
+ "shipment_date": {
+ "type": "string",
+ "description": "Planned ship date, ISO 8601 (default now)."
+ },
+ "carrier_accounts": {
+ "type": "array",
+ "description": "Only rates from these carrier account object_ids.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note, up to 100 characters."
+ },
+ "async": {
+ "type": "boolean",
+ "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.",
+ "default": false
+ }
+ },
+ "required": [
+ "address_from",
+ "address_to",
+ "parcels"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/shipments",
+ "bodyMapping": {
+ "address_from": "$address_from",
+ "address_to": "$address_to",
+ "parcels": "$parcels",
+ "address_return": "$address_return",
+ "customs_declaration": "$customs_declaration",
+ "extra": "$extra",
+ "shipment_date": "$shipment_date",
+ "carrier_accounts": "$carrier_accounts",
+ "metadata": "$metadata",
+ "async": "$async"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_shipment",
+ "description": "Read a shipment with its addresses, parcels, rates and messages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "shipment_id": {
+ "type": "string",
+ "description": "The shipment object_id."
+ }
+ },
+ "required": [
+ "shipment_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/shipments/{shipment_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_list_shipments",
+ "description": "List shipments, newest first, with status and addresses (Shippo keeps the last 390 days).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "object_created_gte": {
+ "type": "string",
+ "description": "Created at or after, ISO 8601 UTC, e.g. \"2026-10-01T00:00:00\"."
+ },
+ "object_created_lte": {
+ "type": "string",
+ "description": "Created at or before, ISO 8601 UTC."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/shipments",
+ "queryParams": {
+ "page": "$page",
+ "results": "$results",
+ "object_created_gte": "$object_created_gte",
+ "object_created_lte": "$object_created_lte"
+ }
+ }
+ },
+ {
+ "name": "shippo_list_shipment_rates",
+ "description": "List the rates of a shipment again (the same rates[] shippo_create_shipment returned), with paging.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "shipment_id": {
+ "type": "string",
+ "description": "The shipment object_id."
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": [
+ "shipment_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/shipments/{shipment_id}/rates",
+ "encodePathParams": true,
+ "queryParams": {
+ "page": "$page",
+ "results": "$results"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_rate",
+ "description": "Read one rate: amount, currency, provider, service level, estimated days, duration terms and zone.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "rate_id": {
+ "type": "string",
+ "description": "The rate object_id."
+ }
+ },
+ "required": [
+ "rate_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/rates/{rate_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_buy_label",
+ "description": "Buy a shipping label for a rate. With a live key this charges the label price to the Shippo account immediately: confirm carrier, service, price and addresses with the user first. Returns the transaction with status, tracking_number, tracking_url_provider and label_url (expiring link to the label file).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "rate": {
+ "type": "string",
+ "description": "The object_id of the chosen rate."
+ },
+ "label_file_type": {
+ "type": "string",
+ "description": "PDF, PDF_4x6, PDF_4x8, PDF_A4, PDF_A5, PDF_A6, PDF_2.3x7.5, PNG, PNG_2.3x7.5 or ZPLII; the account default when omitted.",
+ "enum": [
+ "PDF",
+ "PDF_4x6",
+ "PDF_4x8",
+ "PDF_A4",
+ "PDF_A5",
+ "PDF_A6",
+ "PDF_2.3x7.5",
+ "PNG",
+ "PNG_2.3x7.5",
+ "ZPLII"
+ ]
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note, e.g. an order number, up to 100 characters."
+ },
+ "async": {
+ "type": "boolean",
+ "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.",
+ "default": false
+ }
+ },
+ "required": [
+ "rate"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/transactions",
+ "bodyMapping": {
+ "rate": "$rate",
+ "label_file_type": "$label_file_type",
+ "metadata": "$metadata",
+ "async": "$async"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "shippo_list_labels",
+ "description": "List purchased labels (transactions), newest first, with status, tracking number and tracking status.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_status": {
+ "type": "string",
+ "description": "SUCCESS, ERROR, QUEUED, WAITING, REFUNDED, REFUNDPENDING or REFUNDREJECTED."
+ },
+ "tracking_status": {
+ "type": "string",
+ "description": "UNKNOWN, PRE_TRANSIT, TRANSIT, DELIVERED, RETURNED or FAILURE."
+ },
+ "rate": {
+ "type": "string",
+ "description": "Only the label bought for this rate object_id."
+ },
+ "object_created_gte": {
+ "type": "string",
+ "description": "Created at or after, ISO 8601 UTC."
+ },
+ "object_created_lte": {
+ "type": "string",
+ "description": "Created at or before, ISO 8601 UTC."
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/transactions",
+ "queryParams": {
+ "object_status": "$object_status",
+ "tracking_status": "$tracking_status",
+ "rate": "$rate",
+ "object_created_gte": "$object_created_gte",
+ "object_created_lte": "$object_created_lte",
+ "page": "$page",
+ "results": "$results"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_label",
+ "description": "Read one purchased label (transaction): status, tracking number and status, ETA, a fresh label_url and commercial invoice URL, and messages.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "transaction_id": {
+ "type": "string",
+ "description": "The transaction object_id."
+ }
+ },
+ "required": [
+ "transaction_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/transactions/{transaction_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_get_tracking",
+ "description": "Read the tracking status and history of a parcel by carrier and tracking number, for labels bought anywhere. Returns tracking_status (status, details, time, location), tracking_history[] and eta.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "carrier": {
+ "type": "string",
+ "description": "Carrier token as Shippo names it, e.g. \"usps\", \"ups\", \"fedex\", \"dhl_express\" (see the carrier field of shippo_list_carrier_accounts); \"shippo\" for test tracking numbers."
+ },
+ "tracking_number": {
+ "type": "string",
+ "description": "The tracking number; with carrier \"shippo\": SHIPPO_PRE_TRANSIT, SHIPPO_TRANSIT, SHIPPO_DELIVERED, SHIPPO_RETURNED, SHIPPO_FAILURE or SHIPPO_UNKNOWN."
+ }
+ },
+ "required": [
+ "carrier",
+ "tracking_number"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/tracks/{carrier}/{tracking_number}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_register_tracking",
+ "description": "Register a tracking number for status updates, which Shippo then sends to the account's \"track updated\" webhook (set up in the Shippo dashboard). Returns the current tracking status.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "carrier": {
+ "type": "string",
+ "description": "Carrier token, e.g. \"usps\", \"dhl_express\"."
+ },
+ "tracking_number": {
+ "type": "string",
+ "description": "The tracking number."
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note, up to 100 characters, sent back with each update."
+ }
+ },
+ "required": [
+ "carrier",
+ "tracking_number"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/tracks",
+ "bodyMapping": {
+ "carrier": "$carrier",
+ "tracking_number": "$tracking_number",
+ "metadata": "$metadata"
+ }
+ }
+ },
+ {
+ "name": "shippo_list_carrier_accounts",
+ "description": "List the carrier accounts usable for rates and labels: object_id, carrier, account_id, active, whether it is a Shippo-provided account, and optionally their service levels.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "carrier": {
+ "type": "string",
+ "description": "Only this carrier, e.g. \"usps\", \"ups\", \"dhl_express\"."
+ },
+ "service_levels": {
+ "type": "boolean",
+ "description": "true to include each account's service levels."
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/carrier_accounts",
+ "queryParams": {
+ "carrier": "$carrier",
+ "service_levels": "$service_levels",
+ "page": "$page",
+ "results": "$results"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_carrier_account",
+ "description": "Read one carrier account by object_id: carrier, account number, active flag and parameters.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "carrier_account_id": {
+ "type": "string",
+ "description": "The carrier account object_id."
+ }
+ },
+ "required": [
+ "carrier_account_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/carrier_accounts/{carrier_account_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_create_customs_item",
+ "description": "Create a customs item (one line of a customs declaration). Returns object_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "description": {
+ "type": "string",
+ "description": "What it is, e.g. \"Cotton T-shirt\"."
+ },
+ "quantity": {
+ "type": "integer",
+ "description": "Number of pieces.",
+ "minimum": 1
+ },
+ "net_weight": {
+ "type": "string",
+ "description": "Total net weight of these pieces, e.g. \"0.4\"."
+ },
+ "mass_unit": {
+ "type": "string",
+ "description": "g, kg, lb or oz.",
+ "enum": [
+ "g",
+ "kg",
+ "lb",
+ "oz"
+ ]
+ },
+ "value_amount": {
+ "type": "string",
+ "description": "Total value of these pieces, e.g. \"25.00\"."
+ },
+ "value_currency": {
+ "type": "string",
+ "description": "Currency, e.g. \"EUR\"."
+ },
+ "origin_country": {
+ "type": "string",
+ "description": "Country of manufacture, ISO 2."
+ },
+ "tariff_number": {
+ "type": "string",
+ "description": "HS tariff number, e.g. \"610910\"."
+ },
+ "sku_code": {
+ "type": "string",
+ "description": "Your SKU."
+ },
+ "eccn_ear99": {
+ "type": "string",
+ "description": "Export control classification (US exports)."
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note."
+ }
+ },
+ "required": [
+ "description",
+ "quantity",
+ "net_weight",
+ "mass_unit",
+ "value_amount",
+ "value_currency",
+ "origin_country"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/customs/items",
+ "bodyMapping": {
+ "description": "$description",
+ "quantity": "$quantity",
+ "net_weight": "$net_weight",
+ "mass_unit": "$mass_unit",
+ "value_amount": "$value_amount",
+ "value_currency": "$value_currency",
+ "origin_country": "$origin_country",
+ "tariff_number": "$tariff_number",
+ "sku_code": "$sku_code",
+ "eccn_ear99": "$eccn_ear99",
+ "metadata": "$metadata"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_customs_item",
+ "description": "Read a customs item by object_id: description, quantity, weight, value, origin and tariff number.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customs_item_id": {
+ "type": "string",
+ "description": "The customs item object_id."
+ }
+ },
+ "required": [
+ "customs_item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/customs/items/{customs_item_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_create_customs_declaration",
+ "description": "Create a customs declaration for an international shipment from customs items. Pass its object_id as customs_declaration to shippo_create_shipment.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contents_type": {
+ "type": "string",
+ "description": "DOCUMENTS, GIFT, SAMPLE, MERCHANDISE, HUMANITARIAN_DONATION, RETURN_MERCHANDISE or OTHER.",
+ "enum": [
+ "DOCUMENTS",
+ "GIFT",
+ "SAMPLE",
+ "MERCHANDISE",
+ "HUMANITARIAN_DONATION",
+ "RETURN_MERCHANDISE",
+ "OTHER"
+ ]
+ },
+ "contents_explanation": {
+ "type": "string",
+ "description": "Required when contents_type is OTHER."
+ },
+ "non_delivery_option": {
+ "type": "string",
+ "description": "ABANDON or RETURN.",
+ "enum": [
+ "ABANDON",
+ "RETURN"
+ ]
+ },
+ "certify": {
+ "type": "boolean",
+ "description": "true: the signer certifies the information is correct (required)."
+ },
+ "certify_signer": {
+ "type": "string",
+ "description": "Name of the person certifying."
+ },
+ "items": {
+ "type": "array",
+ "description": "Customs item object_ids (or item objects).",
+ "items": {
+ "anyOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ },
+ "incoterm": {
+ "type": "string",
+ "description": "DDP, DDU, FCA, DAP or eDAP.",
+ "enum": [
+ "DDP",
+ "DDU",
+ "FCA",
+ "DAP",
+ "eDAP"
+ ]
+ },
+ "eel_pfc": {
+ "type": "string",
+ "description": "US export exemption, e.g. NOEEI_30_37_a."
+ },
+ "invoice": {
+ "type": "string",
+ "description": "Commercial invoice number."
+ },
+ "exporter_identification": {
+ "type": "object",
+ "description": "e.g. {\"eori_number\": \"DE123456789012345\"}."
+ },
+ "metadata": {
+ "type": "string",
+ "description": "Your own note."
+ }
+ },
+ "required": [
+ "contents_type",
+ "non_delivery_option",
+ "certify",
+ "certify_signer",
+ "items"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/customs/declarations",
+ "bodyMapping": {
+ "contents_type": "$contents_type",
+ "contents_explanation": "$contents_explanation",
+ "non_delivery_option": "$non_delivery_option",
+ "certify": "$certify",
+ "certify_signer": "$certify_signer",
+ "items": "$items",
+ "incoterm": "$incoterm",
+ "eel_pfc": "$eel_pfc",
+ "invoice": "$invoice",
+ "exporter_identification": "$exporter_identification",
+ "metadata": "$metadata"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_customs_declaration",
+ "description": "Read a customs declaration by object_id, with its items, contents type, incoterm and signer.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customs_declaration_id": {
+ "type": "string",
+ "description": "The customs declaration object_id."
+ }
+ },
+ "required": [
+ "customs_declaration_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/customs/declarations/{customs_declaration_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_list_customs_declarations",
+ "description": "List customs declarations, newest first, with contents type, items and certification details.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/customs/declarations",
+ "queryParams": {
+ "page": "$page",
+ "results": "$results"
+ }
+ }
+ },
+ {
+ "name": "shippo_request_refund",
+ "description": "Void a purchased label and ask for its price back. The label can no longer be used. Only unused labels up to 90 days old; the money comes back after the carrier confirms (days to weeks). Confirm with the user first. Returns the refund with status QUEUED, PENDING, SUCCESS or ERROR.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "transaction": {
+ "type": "string",
+ "description": "The transaction (label) object_id."
+ },
+ "async": {
+ "type": "boolean",
+ "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.",
+ "default": false
+ }
+ },
+ "required": [
+ "transaction"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/refunds",
+ "bodyMapping": {
+ "transaction": "$transaction",
+ "async": "$async"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "shippo_list_refunds",
+ "description": "List label refunds with their status (QUEUED, PENDING, SUCCESS, ERROR) and the label they belong to.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/refunds/"
+ }
+ },
+ {
+ "name": "shippo_get_refund",
+ "description": "Read one label refund by object_id: status, label (transaction) and dates.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "refund_id": {
+ "type": "string",
+ "description": "The refund object_id."
+ }
+ },
+ "required": [
+ "refund_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/refunds/{refund_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "shippo_create_manifest",
+ "description": "Create a manifest (scan form) for a carrier pickup: one document listing the labels of one carrier account, ship date and origin address. Not available with a test key. Returns status and documents[] (URLs).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "carrier_account": {
+ "type": "string",
+ "description": "Carrier account object_id."
+ },
+ "shipment_date": {
+ "type": "string",
+ "description": "Ship date, ISO 8601, e.g. \"2026-10-12T09:00:00Z\"."
+ },
+ "address_from": {
+ "type": "string",
+ "description": "Origin address object_id (the same as on the labels)."
+ },
+ "transactions": {
+ "type": "array",
+ "description": "Label (transaction) object_ids; all eligible labels of that day when omitted.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "async": {
+ "type": "boolean",
+ "description": "false (default here) waits for the result; true returns at once with status QUEUED, then read the object again.",
+ "default": false
+ }
+ },
+ "required": [
+ "carrier_account",
+ "shipment_date",
+ "address_from"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/manifests",
+ "bodyMapping": {
+ "carrier_account": "$carrier_account",
+ "shipment_date": "$shipment_date",
+ "address_from": "$address_from",
+ "transactions": "$transactions",
+ "async": "$async"
+ }
+ }
+ },
+ {
+ "name": "shippo_list_manifests",
+ "description": "List manifests (scan forms), newest first, with carrier account, ship date, status and documents.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "results": {
+ "type": "integer",
+ "description": "Results per page, up to 100.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/manifests",
+ "queryParams": {
+ "page": "$page",
+ "results": "$results"
+ }
+ }
+ },
+ {
+ "name": "shippo_get_manifest",
+ "description": "Read one manifest (scan form) with its status, the labels on it, document URLs and errors.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "manifest_id": {
+ "type": "string",
+ "description": "The manifest object_id."
+ }
+ },
+ "required": [
+ "manifest_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/manifests/{manifest_id}",
+ "encodePathParams": true
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/shippo.live.spec.ts b/packages/backend/src/adapters/intl/shippo.live.spec.ts
new file mode 100644
index 000000000..d45e7f4d1
--- /dev/null
+++ b/packages/backend/src/adapters/intl/shippo.live.spec.ts
@@ -0,0 +1,351 @@
+import axios from 'axios';
+import * as adapter from './shippo.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { interpolateConnectorConfig, interpolateDeep } from '../../common/env-interpolation.util';
+import { describeAdapterEnvVars } from '../env-var-meta';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the Shippo adapter:
+ *
+ * 1. Static: always runs. Pins the `ShippoToken` Authorization header, the
+ * token pattern, every tool's method and URL, the shipment, label,
+ * customs and refund bodies (synchronous by default), and which tools
+ * are read-only or destructive.
+ *
+ * 2. Live: skipped unless SHIPPO_API_TOKEN is set. Writes need a test token
+ * (shippo_test_...), where labels are free:
+ *
+ * SHIPPO_API_TOKEN=shippo_test_... npx jest src/adapters/intl/shippo.live.spec.ts
+ *
+ * Reads carrier accounts, parcel templates, addresses, shipments, labels,
+ * customs declarations, refunds, manifests and test tracking numbers.
+ *
+ * SHIPPO_LIVE_WRITE=1 (test token only) runs the whole flow: two US
+ * addresses (one validated), a parcel, a shipment with rates, a USPS test
+ * label bought from the cheapest rate, read back and listed, tracking
+ * registered, a customs item and declaration, and in `finally` a refund
+ * of the label, read back. Test addresses and parcels cannot be deleted;
+ * they stay in the test data, labelled "AnythingMCP test".
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string; params?: Record };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const BASE = 'https://api.goshippo.com';
+const execute = (name: string, token: string, params: Record) => {
+ const env = { SHIPPO_API_TOKEN: token };
+ const { config, endpointMapping } = interpolateConnectorConfig(
+ { baseUrl: a.connector.baseUrl, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ env,
+ );
+ return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute(
+ { baseUrl: config.baseUrl, authType: a.connector.authType, authConfig: interpolateDeep({ ...a.connector.authConfig }, env), headers: config.headers },
+ endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ ) as Promise;
+};
+const call = (name: string, params: Record) => execute(name, 'shippo_test_abc123', params);
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('shippo adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 11 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/test key/);
+ });
+
+ it('sends the token as "Authorization: ShippoToken " and no API version header', async () => {
+ expect(a.connector.authType).toBe('API_KEY');
+ expect(a.connector.authConfig).toEqual({ headerName: 'Authorization', apiKey: 'ShippoToken {{SHIPPO_API_TOKEN}}' });
+ expect(a.connector.baseUrl).toBe(BASE);
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('shippo_list_carrier_accounts', {});
+ expect(sent().headers.Authorization).toBe('ShippoToken shippo_test_abc123');
+ // A version header would change the whole account's default version.
+ expect(Object.keys(sent().headers).map((h) => h.toLowerCase())).not.toContain('shippo-api-version');
+ });
+
+ it('accepts test and live tokens only, and marks the token secret', () => {
+ expect(a.requiredEnvVars).toEqual(['SHIPPO_API_TOKEN']);
+ const token = new RegExp(a.envVarMeta.SHIPPO_API_TOKEN.pattern!);
+ expect(token.test('shippo_test_5f1b2c3d4e')).toBe(true);
+ expect(token.test('shippo_live_5f1b2c3d4e')).toBe(true);
+ expect(token.test('ShippoToken shippo_test_5f1b')).toBe(false);
+ expect(describeAdapterEnvVars(a as never).find((d) => d.name === 'SHIPPO_API_TOKEN')!.secret).toBe(true);
+ });
+
+ it('probes with one carrier account and health-checks the same list', () => {
+ expect(a.probe).toEqual({ tool: 'shippo_list_carrier_accounts', params: { results: 1 } });
+ expect(a.connector.healthcheckPath).toBe('/carrier_accounts?results=1');
+ });
+
+ it('prefixes every tool with shippo_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^shippo_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('marks reads read-only and buying or refunding a label destructive', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'shippo_get_address',
+ 'shippo_get_carrier_account',
+ 'shippo_get_customs_declaration',
+ 'shippo_get_customs_item',
+ 'shippo_get_label',
+ 'shippo_get_manifest',
+ 'shippo_get_parcel',
+ 'shippo_get_rate',
+ 'shippo_get_refund',
+ 'shippo_get_shipment',
+ 'shippo_get_tracking',
+ 'shippo_list_addresses',
+ 'shippo_list_carrier_accounts',
+ 'shippo_list_customs_declarations',
+ 'shippo_list_labels',
+ 'shippo_list_manifests',
+ 'shippo_list_parcel_templates',
+ 'shippo_list_refunds',
+ 'shippo_list_shipment_rates',
+ 'shippo_list_shipments',
+ 'shippo_validate_address',
+ ]);
+ for (const name of ['shippo_buy_label', 'shippo_request_refund']) {
+ const ann = annotationsOf(tool(name));
+ expect(`${name}:${ann.readOnlyHint}:${ann.destructiveHint}:${ann.idempotentHint}`).toBe(`${name}:false:true:false`);
+ expect(tool(name).description).toMatch(/confirm/i);
+ }
+ expect(annotationsOf(tool('shippo_create_shipment')).destructiveHint).toBe(false);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bshippo_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bshippo_[a-z_]+/g)]),
+ ]
+ .map((m) => m[0])
+ // Key prefixes, not tool names.
+ .filter((m) => m !== 'shippo_test_' && m !== 'shippo_live_');
+ expect(mentioned.length).toBeGreaterThan(8);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ const table: Array<[string, Record, string, string]> = [
+ ['shippo_create_address', { country: 'US' }, 'POST', '/addresses'],
+ ['shippo_get_address', { address_id: 'a1' }, 'GET', '/addresses/a1'],
+ ['shippo_validate_address', { address_id: 'a1' }, 'GET', '/addresses/a1/validate'],
+ ['shippo_list_addresses', {}, 'GET', '/addresses'],
+ ['shippo_create_parcel', { weight: '1', mass_unit: 'kg' }, 'POST', '/parcels'],
+ ['shippo_get_parcel', { parcel_id: 'p1' }, 'GET', '/parcels/p1'],
+ ['shippo_list_parcel_templates', {}, 'GET', '/parcel-templates'],
+ ['shippo_create_shipment', { address_from: 'a1', address_to: 'a2', parcels: ['p1'] }, 'POST', '/shipments'],
+ ['shippo_get_shipment', { shipment_id: 's1' }, 'GET', '/shipments/s1'],
+ ['shippo_list_shipments', {}, 'GET', '/shipments'],
+ ['shippo_list_shipment_rates', { shipment_id: 's1' }, 'GET', '/shipments/s1/rates'],
+ ['shippo_get_rate', { rate_id: 'r1' }, 'GET', '/rates/r1'],
+ ['shippo_buy_label', { rate: 'r1' }, 'POST', '/transactions'],
+ ['shippo_list_labels', {}, 'GET', '/transactions'],
+ ['shippo_get_label', { transaction_id: 't1' }, 'GET', '/transactions/t1'],
+ ['shippo_get_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_TRANSIT' }, 'GET', '/tracks/shippo/SHIPPO_TRANSIT'],
+ ['shippo_register_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_TRANSIT' }, 'POST', '/tracks'],
+ ['shippo_list_carrier_accounts', {}, 'GET', '/carrier_accounts'],
+ ['shippo_get_carrier_account', { carrier_account_id: 'c1' }, 'GET', '/carrier_accounts/c1'],
+ ['shippo_create_customs_item', { description: 'T-shirt', quantity: 1, net_weight: '0.2', mass_unit: 'kg', value_amount: '10', value_currency: 'EUR', origin_country: 'DE' }, 'POST', '/customs/items'],
+ ['shippo_get_customs_item', { customs_item_id: 'ci1' }, 'GET', '/customs/items/ci1'],
+ ['shippo_create_customs_declaration', { contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'Ana', items: ['ci1'] }, 'POST', '/customs/declarations'],
+ ['shippo_get_customs_declaration', { customs_declaration_id: 'cd1' }, 'GET', '/customs/declarations/cd1'],
+ ['shippo_list_customs_declarations', {}, 'GET', '/customs/declarations'],
+ ['shippo_request_refund', { transaction: 't1' }, 'POST', '/refunds'],
+ ['shippo_list_refunds', {}, 'GET', '/refunds/'],
+ ['shippo_get_refund', { refund_id: 'rf1' }, 'GET', '/refunds/rf1'],
+ ['shippo_create_manifest', { carrier_account: 'c1', shipment_date: '2026-10-12T09:00:00Z', address_from: 'a1' }, 'POST', '/manifests'],
+ ['shippo_list_manifests', {}, 'GET', '/manifests'],
+ ['shippo_get_manifest', { manifest_id: 'm1' }, 'GET', '/manifests/m1'],
+ ];
+
+ it('lists every tool in the URL table', () => {
+ expect(table.map(([name]) => name).sort()).toEqual(a.tools.map((t) => t.name).sort());
+ });
+
+ it.each(table)('%s sends %s to the right URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(expect.objectContaining({ method, url: `${BASE}${path}` }));
+ });
+
+ it('creates shipments, labels, refunds and manifests synchronously by default', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ const from = { name: 'A', street1: '215 Clayton St.', city: 'San Francisco', state: 'CA', zip: '94117', country: 'US' };
+ await call('shippo_create_shipment', { address_from: from, address_to: 'addr-2', parcels: [{ length: '10', width: '10', height: '10', distance_unit: 'in', weight: '1', mass_unit: 'lb' }] });
+ expect(sent().data).toEqual({ address_from: from, address_to: 'addr-2', parcels: [{ length: '10', width: '10', height: '10', distance_unit: 'in', weight: '1', mass_unit: 'lb' }], async: false });
+ for (const [name, params, body] of [
+ ['shippo_buy_label', { rate: 'r1', label_file_type: 'PDF_A6' }, { rate: 'r1', label_file_type: 'PDF_A6', async: false }],
+ ['shippo_request_refund', { transaction: 't1' }, { transaction: 't1', async: false }],
+ ['shippo_create_manifest', { carrier_account: 'c1', shipment_date: '2026-10-12T09:00:00Z', address_from: 'a1' }, { carrier_account: 'c1', shipment_date: '2026-10-12T09:00:00Z', address_from: 'a1', async: false }],
+ ] as Array<[string, Record, Record]>) {
+ mockedAxios.mockClear();
+ await call(name, params);
+ expect(sent().data).toEqual(body);
+ }
+ });
+
+ it('passes customs items and the declaration as Shippo names them', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('shippo_create_customs_declaration', { contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'Ana Diaz', items: ['ci1'], incoterm: 'DDU' });
+ expect(sent().data).toEqual({ contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'Ana Diaz', items: ['ci1'], incoterm: 'DDU' });
+ });
+});
+
+const TOKEN = process.env.SHIPPO_API_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('shippo adapter: live API', () => {
+ const write = process.env.SHIPPO_LIVE_WRITE === '1' && !!TOKEN?.startsWith('shippo_test_');
+ beforeAll(() => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ });
+ const run = (name: string, params: Record = {}): Promise => execute(name, TOKEN as string, params);
+
+ it('reads carrier accounts, parcel templates and the lists', async () => {
+ const carriers = await run('shippo_list_carrier_accounts', { results: 5, service_levels: true });
+ expect(Array.isArray(carriers.results)).toBe(true);
+ if (carriers.results.length) {
+ const one = await run('shippo_get_carrier_account', { carrier_account_id: carriers.results[0].object_id });
+ expect(one.object_id).toBe(carriers.results[0].object_id);
+ }
+ const templates = await run('shippo_list_parcel_templates', { carrier: 'usps' });
+ expect(templates).toBeDefined();
+ for (const name of ['shippo_list_addresses', 'shippo_list_shipments', 'shippo_list_labels', 'shippo_list_customs_declarations', 'shippo_list_manifests']) {
+ const page = await run(name, { results: 2 });
+ expect(Array.isArray(page.results)).toBe(true);
+ }
+ const refunds = await run('shippo_list_refunds');
+ expect(refunds).toBeDefined();
+ const labels = await run('shippo_list_labels', { results: 1 });
+ if (labels.results.length) {
+ const one = await run('shippo_get_label', { transaction_id: labels.results[0].object_id });
+ expect(one.object_id).toBe(labels.results[0].object_id);
+ }
+ }, 90_000);
+
+ (TOKEN?.startsWith('shippo_test_') ? it : it.skip)('reads a test tracking number', async () => {
+ const track = await run('shippo_get_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_TRANSIT' });
+ expect(track.tracking_status.status).toBe('TRANSIT');
+ }, 30_000);
+
+ (write ? it : it.skip)('validates addresses, rates a shipment, buys a test label, tracks it, prepares customs and refunds the label', async () => {
+ const from = await run('shippo_create_address', {
+ name: 'AnythingMCP test sender', company: 'AnythingMCP test', street1: '215 Clayton St.', city: 'San Francisco', state: 'CA', zip: '94117', country: 'US',
+ phone: '+1 555 341 9393', email: 'anythingmcp-test@example.com', validate: true,
+ });
+ expect(from.validation_results).toBeDefined();
+ const to = await run('shippo_create_address', {
+ name: 'AnythingMCP test recipient', street1: '965 Mission St', city: 'San Francisco', state: 'CA', zip: '94103', country: 'US', phone: '+1 555 341 9394', email: 'anythingmcp-test@example.com',
+ });
+ // Validation saves the standardized address as a new object (own
+ // object_id, ZIP+4) and leaves the original untouched; ship to the new one.
+ const validated = await run('shippo_validate_address', { address_id: to.object_id });
+ expect(validated.object_id).toMatch(/^[0-9a-f]{32}$/);
+ expect(validated.validation_results.is_valid).toBe(true);
+ expect(validated.zip.startsWith('94103')).toBe(true);
+ expect((await run('shippo_get_address', { address_id: from.object_id })).object_id).toBe(from.object_id);
+
+ const parcel = await run('shippo_create_parcel', { length: '10', width: '8', height: '4', distance_unit: 'in', weight: '2', mass_unit: 'lb', metadata: 'AnythingMCP test' });
+ expect((await run('shippo_get_parcel', { parcel_id: parcel.object_id })).object_id).toBe(parcel.object_id);
+
+ const shipment = await run('shippo_create_shipment', { address_from: from.object_id, address_to: validated.object_id, parcels: [parcel.object_id], metadata: 'AnythingMCP test' });
+ expect(shipment.status).toBe('SUCCESS');
+ expect((await run('shippo_get_shipment', { shipment_id: shipment.object_id })).object_id).toBe(shipment.object_id);
+ const rates = await run('shippo_list_shipment_rates', { shipment_id: shipment.object_id });
+ expect(rates.results.length).toBeGreaterThan(0);
+ const usps = rates.results.filter((r: { provider: string }) => r.provider === 'USPS');
+ const cheapest = (usps.length ? usps : rates.results).sort((x: { amount: string }, y: { amount: string }) => Number(x.amount) - Number(y.amount))[0];
+ expect((await run('shippo_get_rate', { rate_id: cheapest.object_id })).object_id).toBe(cheapest.object_id);
+
+ const label = await run('shippo_buy_label', { rate: cheapest.object_id, label_file_type: 'PDF', metadata: 'AnythingMCP test' });
+ try {
+ expect(label.status).toBe('SUCCESS');
+ expect(label.test).toBe(true);
+ const read = await run('shippo_get_label', { transaction_id: label.object_id });
+ expect(read.tracking_number).toBe(label.tracking_number);
+ const listed = await run('shippo_list_labels', { rate: cheapest.object_id });
+ expect(listed.results.map((t: { object_id: string }) => t.object_id)).toContain(label.object_id);
+ const registered = await run('shippo_register_tracking', { carrier: 'shippo', tracking_number: 'SHIPPO_DELIVERED', metadata: 'AnythingMCP test' });
+ expect(registered.tracking_number).toBe('SHIPPO_DELIVERED');
+
+ const item = await run('shippo_create_customs_item', {
+ description: 'AnythingMCP test T-shirt', quantity: 2, net_weight: '0.4', mass_unit: 'kg', value_amount: '20.00', value_currency: 'USD', origin_country: 'US', tariff_number: '610910',
+ });
+ expect((await run('shippo_get_customs_item', { customs_item_id: item.object_id })).object_id).toBe(item.object_id);
+ const declaration = await run('shippo_create_customs_declaration', {
+ contents_type: 'MERCHANDISE', non_delivery_option: 'RETURN', certify: true, certify_signer: 'AnythingMCP Test', items: [item.object_id], incoterm: 'DDU', eel_pfc: 'NOEEI_30_37_a',
+ });
+ expect((await run('shippo_get_customs_declaration', { customs_declaration_id: declaration.object_id })).object_id).toBe(declaration.object_id);
+ } finally {
+ const refund = await run('shippo_request_refund', { transaction: label.object_id });
+ expect(refund.status).toMatch(/QUEUED|PENDING|SUCCESS/);
+ const again = await run('shippo_get_refund', { refund_id: refund.object_id });
+ expect(again.transaction).toBe(label.object_id);
+ }
+ }, 180_000);
+});
diff --git a/packages/backend/src/adapters/intl/square.json b/packages/backend/src/adapters/intl/square.json
new file mode 100644
index 000000000..1ddf556cc
--- /dev/null
+++ b/packages/backend/src/adapters/intl/square.json
@@ -0,0 +1,1451 @@
+{
+ "slug": "square",
+ "name": "Square",
+ "description": "Let your AI work with your Square business: look up payments, refunds and orders, manage customers, search and edit the item catalog, check inventory, create and send invoices, and see locations and team members. Sandbox or production, with your own access token.",
+ "instructions": "## Square\nThis connector works with one Square seller account through the **Square API** (`https://connect.squareup.com`, or `https://connect.squareupsandbox.com` for the sandbox), version `2026-09-16`: business and locations, payments and refunds, orders, customers, the item catalog, inventory counts, invoices and team members.\n\n**Setup (about 5 minutes)**\n1. Sign in at https://developer.squareup.com/apps with the Square account and click **Create an application** (any name).\n2. Open the application → **Credentials**. The toggle at the top switches between **Sandbox** and **Production**.\n3. Copy the **access token** of that environment into `SQUARE_ACCESS_TOKEN`. It is a personal access token with full access to your own account, which is what this connector needs; it does not expire, but anyone holding it can act for the business, so keep it secret and replace it on the same page if it leaks.\n4. Set `SQUARE_API_URL` to `https://connect.squareupsandbox.com` for the sandbox token or `https://connect.squareup.com` for the production token. A token only works against its own environment (401 otherwise).\n5. Install and run `square_get_merchant` to check the business name.\n\n**Sandbox**: the sandbox token belongs to a default test seller. To look at the results as a seller, open the developer console → **Sandbox test accounts** → the account → **Square Dashboard**. The sandbox never emails invoices; use the invoice's `public_url`.\n\n**Money and customer emails: confirm first.** `square_refund_payment` sends money back, `square_publish_invoice` sends the invoice to the customer (email) and makes it payable, and `square_cancel_invoice` and `square_upsert_catalog_object` change data customers see. Before calling them, state what will happen (amount, customer, ids) and wait for the user's yes. `square_delete_customer`, `square_delete_catalog_object` and `square_delete_invoice` are installed switched off.\n\n**Idempotency keys**: every create, refund, publish and upsert takes an `idempotency_key`. Use a fresh UUID per action, and reuse the same key only when retrying the same action after a timeout.\n\n**Money**: amounts are integers in the smallest unit of the currency (`{\"amount\": 1999, \"currency\": \"EUR\"}` is 19.99 EUR); the currency must be the seller's (see `square_get_merchant`). Times are RFC 3339 (`2026-10-01T00:00:00Z`).\n\n**Locations**: most data belongs to a location. `square_list_locations` gives their ids; `\"main\"` works as the id of the main location in `square_get_location`. `square_list_payments` without `location_id` covers only the main location; `square_search_orders` needs 1 to 10 `location_ids`; `square_list_invoices` needs one `location_id`.\n\n**Orders**: `square_search_orders` filters by state (OPEN, COMPLETED, CANCELED), dates and customers. When you filter by a date field, sort by the same field (`\"sort\": {\"sort_field\": \"CLOSED_AT\"}` with a `closed_at` filter). Orders taken offline can take up to 72 hours to appear.\n\n**Invoices**: an invoice is built on an OPEN order. Steps: `square_create_order` (location, line items, customer) → `square_create_invoice` with that `order_id`, the `customer_id`, one `payment_requests` entry (`{\"request_type\": \"BALANCE\", \"due_date\": \"2026-11-01\"}`) and `accepted_payment_methods` (`{\"card\": true}`) → it is a DRAFT until `square_publish_invoice`. Publishing and cancelling need the invoice's current `version` (from `square_get_invoice`). For EMAIL delivery the customer needs an email address. Deleting a draft also cancels its order.\n\n**Customers**: `square_search_customers` filters by email, phone (E.164, `+4976112345`), reference id or dates; a new or changed customer can take a few seconds to become searchable. `square_update_customer` changes only the fields you send; pass `version` to avoid overwriting a newer change.\n\n**Catalog**: `square_list_catalog` takes `types` (ITEM, ITEM_VARIATION, CATEGORY, TAX, DISCOUNT, MODIFIER_LIST, ...). `square_search_catalog_items` searches item names, SKUs and descriptions. `square_upsert_catalog_object` creates or REPLACES a whole object: to change an item, read it with `square_get_catalog_object`, change the copy and send it back with its `version`, or fields you leave out (variations included) are removed. New objects use temporary ids starting with `#`.\n\n**Inventory**: counts are per item variation (not per item) and location: `square_get_inventory_counts` with variation ids, or `square_get_inventory_count` for one.\n\n**Paging**: list and search answers carry `cursor` while more pages exist; pass it back to continue.\n\n**Common errors** (`errors[].code` and `detail`)\n- 401 `UNAUTHORIZED`: wrong token, or a sandbox token against the production URL (or the other way round).\n- 400 `INVALID_REQUEST_ERROR` / `VERSION_MISMATCH`: a field or an outdated `version`; read the object again.\n- 404 `NOT_FOUND`: wrong id, or an object from another location or environment.\n- 429 `RATE_LIMITED`: wait a moment and retry.\n\n**Out of scope here**: taking card payments, gift cards, loyalty, bookings, subscriptions, payouts and webhooks.",
+ "region": "intl",
+ "category": "payments",
+ "icon": "square",
+ "docsUrl": "https://developer.squareup.com/reference/square",
+ "requiredEnvVars": [
+ "SQUARE_ACCESS_TOKEN",
+ "SQUARE_API_URL"
+ ],
+ "appRegistrationUrl": "https://developer.squareup.com/apps",
+ "envVarMeta": {
+ "SQUARE_ACCESS_TOKEN": {
+ "label": "Access token",
+ "kind": "credential",
+ "secret": true,
+ "help": "developer.squareup.com/apps → your application → Credentials → Sandbox or Production → Access token (Show, then copy).",
+ "link": "https://developer.squareup.com/apps",
+ "pattern": "^\\S+$",
+ "patternMessage": "Paste only the token, without the word Bearer and without spaces."
+ },
+ "SQUARE_API_URL": {
+ "label": "Environment",
+ "kind": "address",
+ "help": "https://connect.squareupsandbox.com for a sandbox token, https://connect.squareup.com for a production token.",
+ "example": "https://connect.squareupsandbox.com",
+ "link": "https://developer.squareup.com/docs/devtools/sandbox/overview",
+ "pattern": "^https://connect\\.(squareup|squareupsandbox)\\.com$",
+ "patternMessage": "Use https://connect.squareup.com or https://connect.squareupsandbox.com, without a trailing slash."
+ }
+ },
+ "verifyHints": {
+ "401": "Square refused the access token. Check that it is the token of the same environment as SQUARE_API_URL (sandbox token with the sandbox URL) and that it was not replaced in the developer console.",
+ "403": "The token lacks a permission for this call. A personal access token from the Credentials page has full access; an OAuth token needs the scope named in the error.",
+ "rate_limited": "Square is rate limiting this account. Wait a moment and try again."
+ },
+ "probe": {
+ "tool": "square_get_merchant"
+ },
+ "prerequisites": "A Square account and a free application on developer.squareup.com, whose Credentials page gives a sandbox and a production access token. About 5 minutes.",
+ "connector": {
+ "name": "Square API",
+ "type": "REST",
+ "baseUrl": "{{SQUARE_API_URL}}",
+ "authType": "BEARER_TOKEN",
+ "authConfig": {
+ "token": "{{SQUARE_ACCESS_TOKEN}}"
+ },
+ "headers": {
+ "Square-Version": "2026-09-16",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/v2/merchants/me"
+ },
+ "tools": [
+ {
+ "name": "square_get_merchant",
+ "description": "Read the seller account behind the token: business name, country, currency, language, status and main_location_id. Call it first to confirm which business the connector reaches.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/merchants/me"
+ }
+ },
+ {
+ "name": "square_list_locations",
+ "description": "List the business locations: id, name, address, timezone, currency, status and capabilities. Most other tools need a location id from here.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/locations"
+ }
+ },
+ {
+ "name": "square_get_location",
+ "description": "Read one location by id, or the main location with \"main\": address, hours, currency, capabilities.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_id": {
+ "type": "string",
+ "description": "Location id, or \"main\"."
+ }
+ },
+ "required": [
+ "location_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/locations/{location_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_list_payments",
+ "description": "List payments (newest first by default) with amount, status, card details, order and customer ids, refunds and receipt URL. Without location_id only the main location is listed; without dates the last year.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "begin_time": {
+ "type": "string",
+ "description": "From, RFC 3339 (default one year ago)."
+ },
+ "end_time": {
+ "type": "string",
+ "description": "Until, RFC 3339 (default now)."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "DESC (default, newest first) or ASC.",
+ "enum": [
+ "ASC",
+ "DESC"
+ ]
+ },
+ "location_id": {
+ "type": "string",
+ "description": "Location id (default: the main location)."
+ },
+ "total": {
+ "type": "integer",
+ "description": "Only payments with exactly this total, in the smallest currency unit."
+ },
+ "last_4": {
+ "type": "string",
+ "description": "Last 4 digits of the card."
+ },
+ "card_brand": {
+ "type": "string",
+ "description": "Card brand, e.g. VISA, MASTERCARD."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Payments per page, 1 to 100 (default 100).",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/payments",
+ "queryParams": {
+ "begin_time": "$begin_time",
+ "end_time": "$end_time",
+ "sort_order": "$sort_order",
+ "location_id": "$location_id",
+ "total": "$total",
+ "last_4": "$last_4",
+ "card_brand": "$card_brand",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "square_get_payment",
+ "description": "Read one payment: amounts (total, tip, app fee, refunded), status (APPROVED, PENDING, COMPLETED, CANCELED, FAILED), source and card details, order, customer and receipt.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "payment_id": {
+ "type": "string",
+ "description": "The payment id."
+ }
+ },
+ "required": [
+ "payment_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/payments/{payment_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_list_refunds",
+ "description": "List refunds (newest first by default) across all locations unless one is given: amount, status, reason, payment id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "begin_time": {
+ "type": "string",
+ "description": "From, RFC 3339 (default one year ago)."
+ },
+ "end_time": {
+ "type": "string",
+ "description": "Until, RFC 3339."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "DESC (default) or ASC.",
+ "enum": [
+ "ASC",
+ "DESC"
+ ]
+ },
+ "location_id": {
+ "type": "string",
+ "description": "Only this location."
+ },
+ "status": {
+ "type": "string",
+ "description": "PENDING, COMPLETED, REJECTED or FAILED.",
+ "enum": [
+ "PENDING",
+ "COMPLETED",
+ "REJECTED",
+ "FAILED"
+ ]
+ },
+ "source_type": {
+ "type": "string",
+ "description": "CARD, BANK_ACCOUNT, WALLET, CASH or EXTERNAL."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Refunds per page, 1 to 100.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/refunds",
+ "queryParams": {
+ "begin_time": "$begin_time",
+ "end_time": "$end_time",
+ "sort_order": "$sort_order",
+ "location_id": "$location_id",
+ "status": "$status",
+ "source_type": "$source_type",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "square_get_refund",
+ "description": "Read one refund: amount, status, reason, payment and order ids.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "refund_id": {
+ "type": "string",
+ "description": "The refund id."
+ }
+ },
+ "required": [
+ "refund_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/refunds/{refund_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_refund_payment",
+ "description": "Refund a payment in full or in part. This sends money back to the customer and cannot be undone: confirm the payment, amount and reason with the user first. Returns the refund with status PENDING or COMPLETED.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "payment_id": {
+ "type": "string",
+ "description": "The payment to refund."
+ },
+ "amount_money": {
+ "type": "object",
+ "description": "Money in the smallest currency unit, e.g. {\"amount\": 1999, \"currency\": \"EUR\"} for 19.99 EUR. At most what is left of the payment."
+ },
+ "reason": {
+ "type": "string",
+ "description": "Reason, up to 192 characters."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "payment_id",
+ "amount_money",
+ "idempotency_key"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/refunds",
+ "bodyMapping": {
+ "idempotency_key": "$idempotency_key",
+ "payment_id": "$payment_id",
+ "amount_money": "$amount_money",
+ "reason": "$reason"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "square_search_orders",
+ "description": "Search orders of 1 to 10 locations by state, dates, customers or source, with line items, totals, tenders and fulfillments. Read-only, although it is a POST. Returns orders (or light order_entries) and a cursor.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_ids": {
+ "type": "array",
+ "description": "1 to 10 location ids.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "query": {
+ "type": "object",
+ "description": "Optional filter and sort, e.g. {\"filter\": {\"state_filter\": {\"states\": [\"COMPLETED\"]}, \"date_time_filter\": {\"closed_at\": {\"start_at\": \"2026-10-01T00:00:00Z\", \"end_at\": \"2026-10-10T00:00:00Z\"}}, \"customer_filter\": {\"customer_ids\": [\"...\"]}}, \"sort\": {\"sort_field\": \"CLOSED_AT\", \"sort_order\": \"DESC\"}}. Sort by the same field you filter dates on."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Orders per page, 1 to 1000 (default 500).",
+ "minimum": 1,
+ "maximum": 1000
+ },
+ "return_entries": {
+ "type": "boolean",
+ "description": "true to return only order ids, versions and locations (order_entries)."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ },
+ "required": [
+ "location_ids"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/orders/search",
+ "bodyMapping": {
+ "location_ids": "$location_ids",
+ "query": "$query",
+ "limit": "$limit",
+ "return_entries": "$return_entries",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_get_order",
+ "description": "Read one order: state, line items with modifiers, taxes and discounts, totals, tenders (payments), refunds and fulfillments.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "order_id": {
+ "type": "string",
+ "description": "The order id."
+ }
+ },
+ "required": [
+ "order_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/orders/{order_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_create_order",
+ "description": "Create an OPEN order, for example as the basis of an invoice. Nothing is charged. Returns the order with its id, version and computed totals.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_id": {
+ "type": "string",
+ "description": "The location id."
+ },
+ "line_items": {
+ "type": "array",
+ "description": "Line items: ad hoc {\"name\": \"Repair\", \"quantity\": \"1\", \"base_price_money\": {\"amount\": 4500, \"currency\": \"EUR\"}} or from the catalog {\"catalog_object_id\": \"\", \"quantity\": \"2\"}. quantity is a string.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "customer_id": {
+ "type": "string",
+ "description": "Optional customer id."
+ },
+ "reference_id": {
+ "type": "string",
+ "description": "Optional own reference, e.g. a job number."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "location_id",
+ "line_items",
+ "idempotency_key"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/orders",
+ "bodyMapping": {
+ "order": {
+ "location_id": "$location_id",
+ "line_items": "$line_items",
+ "customer_id": "$customer_id",
+ "reference_id": "$reference_id"
+ },
+ "idempotency_key": "$idempotency_key"
+ }
+ }
+ },
+ {
+ "name": "square_list_customers",
+ "description": "List customer profiles: id, names, company, email, phone, address, reference id, version.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "description": "Customers per page, 1 to 100.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "sort_field": {
+ "type": "string",
+ "description": "DEFAULT (by name) or CREATED_AT.",
+ "enum": [
+ "DEFAULT",
+ "CREATED_AT"
+ ]
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "ASC (default) or DESC.",
+ "enum": [
+ "ASC",
+ "DESC"
+ ]
+ },
+ "count": {
+ "type": "boolean",
+ "description": "true to also return the total count."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/customers",
+ "queryParams": {
+ "limit": "$limit",
+ "sort_field": "$sort_field",
+ "sort_order": "$sort_order",
+ "count": "$count",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "square_search_customers",
+ "description": "Search customer profiles by email, phone, reference id, creation or update time, or group. Read-only, although it is a POST.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "object",
+ "description": "Filter and sort, e.g. {\"filter\": {\"email_address\": {\"fuzzy\": \"acme\"}}} or {\"filter\": {\"phone_number\": {\"exact\": \"+4976112345\"}}} or {\"filter\": {\"created_at\": {\"start_at\": \"2026-09-01T00:00:00Z\"}}, \"sort\": {\"field\": \"CREATED_AT\", \"order\": \"DESC\"}}. email_address, phone_number and reference_id take exact or fuzzy, not both."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Customers per page, 1 to 100.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "count": {
+ "type": "boolean",
+ "description": "true to also return the number of matches."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/customers/search",
+ "bodyMapping": {
+ "query": "$query",
+ "limit": "$limit",
+ "count": "$count",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_get_customer",
+ "description": "Read one customer profile with its current version, groups and preferences.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer id."
+ }
+ },
+ "required": [
+ "customer_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/customers/{customer_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_create_customer",
+ "description": "Create a customer profile. At least one of given_name, family_name, company_name, email_address or phone_number is required. Returns the customer with its id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "given_name": {
+ "type": "string",
+ "description": "First name."
+ },
+ "family_name": {
+ "type": "string",
+ "description": "Last name."
+ },
+ "company_name": {
+ "type": "string",
+ "description": "Company name."
+ },
+ "email_address": {
+ "type": "string",
+ "description": "Email address."
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number, e.g. \"+4976112345\"."
+ },
+ "address": {
+ "type": "object",
+ "description": "Address, e.g. {\"address_line_1\": \"Hauptstr. 1\", \"locality\": \"Freiburg\", \"postal_code\": \"79098\", \"country\": \"DE\"}."
+ },
+ "reference_id": {
+ "type": "string",
+ "description": "Your own id for this customer."
+ },
+ "note": {
+ "type": "string",
+ "description": "A note about the customer."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "description": "Optional unique key, e.g. a UUID, so a retry does not create a duplicate."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/customers",
+ "bodyMapping": {
+ "given_name": "$given_name",
+ "family_name": "$family_name",
+ "company_name": "$company_name",
+ "email_address": "$email_address",
+ "phone_number": "$phone_number",
+ "address": "$address",
+ "reference_id": "$reference_id",
+ "note": "$note",
+ "idempotency_key": "$idempotency_key"
+ }
+ }
+ },
+ {
+ "name": "square_update_customer",
+ "description": "Update a customer profile. Only the fields you send change; send null to clear one. Pass the version from square_get_customer to refuse overwriting a newer change.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer id."
+ },
+ "given_name": {
+ "type": "string",
+ "description": "First name."
+ },
+ "family_name": {
+ "type": "string",
+ "description": "Last name."
+ },
+ "company_name": {
+ "type": "string",
+ "description": "Company name."
+ },
+ "email_address": {
+ "type": "string",
+ "description": "Email address."
+ },
+ "phone_number": {
+ "type": "string",
+ "description": "Phone number."
+ },
+ "address": {
+ "type": "object",
+ "description": "Address object."
+ },
+ "reference_id": {
+ "type": "string",
+ "description": "Your own id."
+ },
+ "note": {
+ "type": "string",
+ "description": "Note."
+ },
+ "version": {
+ "type": "integer",
+ "description": "The current version of the profile."
+ }
+ },
+ "required": [
+ "customer_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/v2/customers/{customer_id}",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "given_name": "$given_name",
+ "family_name": "$family_name",
+ "company_name": "$company_name",
+ "email_address": "$email_address",
+ "phone_number": "$phone_number",
+ "address": "$address",
+ "reference_id": "$reference_id",
+ "note": "$note",
+ "version": "$version"
+ }
+ }
+ },
+ {
+ "name": "square_delete_customer",
+ "description": "Delete a customer profile for good. Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer id."
+ },
+ "version": {
+ "type": "integer",
+ "description": "Optional current version; the delete fails if the profile changed since."
+ }
+ },
+ "required": [
+ "customer_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/v2/customers/{customer_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "version": "$version"
+ }
+ }
+ },
+ {
+ "name": "square_list_catalog",
+ "description": "List catalog objects of the given types (100 per page): items with variations and prices, categories, taxes, discounts, modifier lists.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "types": {
+ "type": "string",
+ "description": "Comma-separated types, e.g. \"ITEM\" or \"ITEM,CATEGORY,TAX\". Others: ITEM_VARIATION, DISCOUNT, MODIFIER_LIST, IMAGE.",
+ "default": "ITEM"
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/catalog/list",
+ "queryParams": {
+ "types": "$types",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "square_search_catalog_items",
+ "description": "Search items by text (name, description, SKU, UPC), category, location, stock level or product type. Read-only, although it is a POST. Returns items and matched_variation_ids.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "text_filter": {
+ "type": "string",
+ "description": "Text to look for, e.g. \"espresso\"."
+ },
+ "category_ids": {
+ "type": "array",
+ "description": "Only these category ids.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "enabled_location_ids": {
+ "type": "array",
+ "description": "Only items enabled at these locations.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "stock_levels": {
+ "type": "array",
+ "description": "OUT or LOW.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "product_types": {
+ "type": "array",
+ "description": "e.g. [\"REGULAR\", \"APPOINTMENTS_SERVICE\"].",
+ "items": {
+ "type": "string"
+ }
+ },
+ "archived_state": {
+ "type": "string",
+ "description": "ARCHIVED_STATE_NOT_ARCHIVED (default), ARCHIVED_STATE_ARCHIVED or ARCHIVED_STATE_ALL."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "ASC or DESC by name.",
+ "enum": [
+ "ASC",
+ "DESC"
+ ]
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Items per page, 1 to 100.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/catalog/search-catalog-items",
+ "bodyMapping": {
+ "text_filter": "$text_filter",
+ "category_ids": "$category_ids",
+ "enabled_location_ids": "$enabled_location_ids",
+ "stock_levels": "$stock_levels",
+ "product_types": "$product_types",
+ "archived_state": "$archived_state",
+ "sort_order": "$sort_order",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_search_catalog_objects",
+ "description": "Search any catalog objects (items, variations, categories, taxes, discounts...) by keywords, exact or prefix attribute values, or change time. Read-only, although it is a POST.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_types": {
+ "type": "array",
+ "description": "Types to search, e.g. [\"ITEM_VARIATION\"]. All top-level types when omitted.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "query": {
+ "type": "object",
+ "description": "e.g. {\"text_query\": {\"keywords\": [\"latte\"]}} (1 to 3 keywords of 3+ characters, all must match), {\"exact_query\": {\"attribute_name\": \"sku\", \"attribute_value\": \"LAT-12\"}} or {\"prefix_query\": {\"attribute_name\": \"name\", \"attribute_prefix\": \"Lat\"}}."
+ },
+ "include_related_objects": {
+ "type": "boolean",
+ "description": "true to also return referenced objects (categories, taxes, images)."
+ },
+ "include_deleted_objects": {
+ "type": "boolean",
+ "description": "true to include deleted objects."
+ },
+ "begin_time": {
+ "type": "string",
+ "description": "Only objects changed after this RFC 3339 time."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Objects per page, up to 1000.",
+ "minimum": 1,
+ "maximum": 1000
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/catalog/search",
+ "bodyMapping": {
+ "object_types": "$object_types",
+ "query": "$query",
+ "include_related_objects": "$include_related_objects",
+ "include_deleted_objects": "$include_deleted_objects",
+ "begin_time": "$begin_time",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_get_catalog_object",
+ "description": "Read one catalog object by id with its version (needed to update it), optionally with the objects it references.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "description": "The catalog object id."
+ },
+ "include_related_objects": {
+ "type": "boolean",
+ "description": "true to also return categories, taxes, modifiers and images it references."
+ }
+ },
+ "required": [
+ "object_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/catalog/object/{object_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "include_related_objects": "$include_related_objects"
+ }
+ }
+ },
+ {
+ "name": "square_upsert_catalog_object",
+ "description": "Create a catalog object, or REPLACE an existing one as a whole: fields left out (variations included) are removed, so for an update send the full object from square_get_catalog_object with its version. Confirm changes to existing items with the user first. Returns catalog_object and id_mappings (temporary #ids to real ids).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object": {
+ "type": "object",
+ "description": "The object. New item: {\"type\": \"ITEM\", \"id\": \"#coffee\", \"item_data\": {\"name\": \"Coffee\", \"variations\": [{\"type\": \"ITEM_VARIATION\", \"id\": \"#coffee-regular\", \"item_variation_data\": {\"item_id\": \"#coffee\", \"name\": \"Regular\", \"pricing_type\": \"FIXED_PRICING\", \"price_money\": {\"amount\": 250, \"currency\": \"EUR\"}}}]}}. Existing objects keep their id and must carry their current version."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "object",
+ "idempotency_key"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/catalog/object",
+ "bodyMapping": {
+ "idempotency_key": "$idempotency_key",
+ "object": "$object"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "square_delete_catalog_object",
+ "description": "Delete a catalog object for good; deleting an item also deletes its variations. Confirm with the user first. Returns deleted_object_ids.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "description": "The catalog object id."
+ }
+ },
+ "required": [
+ "object_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/v2/catalog/object/{object_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_get_inventory_counts",
+ "description": "Read inventory counts for many item variations and locations at once: quantity (a string) per state such as IN_STOCK, newest first. Read-only, although it is a POST.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "catalog_object_ids": {
+ "type": "array",
+ "description": "Item variation ids (not item ids).",
+ "items": {
+ "type": "string"
+ }
+ },
+ "location_ids": {
+ "type": "array",
+ "description": "Location ids; all when omitted.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "updated_after": {
+ "type": "string",
+ "description": "Only counts changed after this RFC 3339 time."
+ },
+ "states": {
+ "type": "array",
+ "description": "States, e.g. [\"IN_STOCK\"]; default IN_STOCK.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Counts per page, 1 to 1000.",
+ "minimum": 1,
+ "maximum": 1000
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/inventory/counts/batch-retrieve",
+ "bodyMapping": {
+ "catalog_object_ids": "$catalog_object_ids",
+ "location_ids": "$location_ids",
+ "updated_after": "$updated_after",
+ "states": "$states",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_get_inventory_count",
+ "description": "Read the inventory count of one item variation, per location and state.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "catalog_object_id": {
+ "type": "string",
+ "description": "The item variation id."
+ },
+ "location_ids": {
+ "type": "string",
+ "description": "Comma-separated location ids; all when omitted."
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ },
+ "required": [
+ "catalog_object_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/inventory/{catalog_object_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "location_ids": "$location_ids",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "square_list_invoices",
+ "description": "List the invoices of one location: id, number, status (DRAFT, UNPAID, SCHEDULED, PARTIALLY_PAID, PAID, CANCELED, ...), recipient, payment requests with due dates and amounts, public_url.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_id": {
+ "type": "string",
+ "description": "The location id (required)."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Invoices per page, 1 to 200.",
+ "minimum": 1,
+ "maximum": 200
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ },
+ "required": [
+ "location_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/invoices",
+ "queryParams": {
+ "location_id": "$location_id",
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ }
+ },
+ {
+ "name": "square_search_invoices",
+ "description": "Search the invoices of one location, optionally of one customer, sorted by invoice date. Read-only, although it is a POST.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_id": {
+ "type": "string",
+ "description": "The location id."
+ },
+ "customer_ids": {
+ "type": "array",
+ "description": "Only invoices of this customer: a list with at most one customer id.",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 1
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "DESC (newest first) or ASC.",
+ "enum": [
+ "ASC",
+ "DESC"
+ ],
+ "default": "DESC"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Invoices per page, 1 to 200.",
+ "minimum": 1,
+ "maximum": 200
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ },
+ "required": [
+ "location_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoices/search",
+ "bodyMapping": {
+ "query": {
+ "filter": {
+ "location_ids": [
+ "$location_id"
+ ],
+ "customer_ids": "$customer_ids"
+ },
+ "sort": {
+ "field": "INVOICE_SORT_DATE",
+ "order": "$sort_order"
+ }
+ },
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_get_invoice",
+ "description": "Read one invoice: status, version (needed to publish or cancel), order, recipient, payment requests with computed amounts, public_url and timestamps.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/invoices/{invoice_id}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "square_create_invoice",
+ "description": "Create a DRAFT invoice for an OPEN order (make one with square_create_order). Nothing is sent until square_publish_invoice. Returns the invoice with id and version.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_id": {
+ "type": "string",
+ "description": "The order's location id."
+ },
+ "order_id": {
+ "type": "string",
+ "description": "An OPEN order at that location."
+ },
+ "customer_id": {
+ "type": "string",
+ "description": "The customer to bill (needs an email for EMAIL delivery)."
+ },
+ "payment_requests": {
+ "type": "array",
+ "description": "1 to 13 payment requests, usually [{\"request_type\": \"BALANCE\", \"due_date\": \"2026-11-01\"}]; with reminders: \"reminders\": [{\"relative_scheduled_days\": -1, \"message\": \"Due tomorrow\"}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "accepted_payment_methods": {
+ "type": "object",
+ "description": "At least one true, e.g. {\"card\": true, \"bank_account\": false, \"square_gift_card\": false}.",
+ "default": {
+ "card": true
+ }
+ },
+ "delivery_method": {
+ "type": "string",
+ "description": "EMAIL (Square emails the invoice) or SHARE_MANUALLY (you share public_url).",
+ "enum": [
+ "EMAIL",
+ "SHARE_MANUALLY"
+ ],
+ "default": "EMAIL"
+ },
+ "title": {
+ "type": "string",
+ "description": "Title shown on the invoice."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description or terms."
+ },
+ "invoice_number": {
+ "type": "string",
+ "description": "Own invoice number; generated when omitted."
+ },
+ "sale_or_service_date": {
+ "type": "string",
+ "description": "Date of the sale or service, YYYY-MM-DD."
+ },
+ "scheduled_at": {
+ "type": "string",
+ "description": "Optional RFC 3339 time to send it later."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "location_id",
+ "order_id",
+ "customer_id",
+ "payment_requests",
+ "idempotency_key"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoices",
+ "bodyMapping": {
+ "invoice": {
+ "location_id": "$location_id",
+ "order_id": "$order_id",
+ "primary_recipient": {
+ "customer_id": "$customer_id"
+ },
+ "payment_requests": "$payment_requests",
+ "accepted_payment_methods": "$accepted_payment_methods",
+ "delivery_method": "$delivery_method",
+ "title": "$title",
+ "description": "$description",
+ "invoice_number": "$invoice_number",
+ "sale_or_service_date": "$sale_or_service_date",
+ "scheduled_at": "$scheduled_at"
+ },
+ "idempotency_key": "$idempotency_key"
+ }
+ }
+ },
+ {
+ "name": "square_publish_invoice",
+ "description": "Publish a draft invoice: Square sends it to the customer (EMAIL delivery) or makes it payable at public_url, and it can no longer be edited freely. Confirm with the user first. Needs the invoice's current version.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The draft invoice id."
+ },
+ "version": {
+ "type": "integer",
+ "description": "The invoice's current version (from square_get_invoice)."
+ },
+ "idempotency_key": {
+ "type": "string",
+ "description": "A unique key for this request, e.g. a fresh UUID. Retrying with the same key returns the first result instead of acting twice."
+ }
+ },
+ "required": [
+ "invoice_id",
+ "version"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoices/{invoice_id}/publish",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "version": "$version",
+ "idempotency_key": "$idempotency_key"
+ }
+ }
+ },
+ {
+ "name": "square_cancel_invoice",
+ "description": "Cancel a published invoice so it can no longer be paid; the customer is notified. Not possible for drafts (delete those) or paid invoices. Cannot be undone: confirm with the user first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice id."
+ },
+ "version": {
+ "type": "integer",
+ "description": "The invoice's current version."
+ }
+ },
+ "required": [
+ "invoice_id",
+ "version"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/invoices/{invoice_id}/cancel",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "version": "$version"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "square_delete_invoice",
+ "description": "Delete a DRAFT invoice; its order is canceled too. Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The draft invoice id."
+ },
+ "version": {
+ "type": "integer",
+ "description": "Optional current version."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/v2/invoices/{invoice_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "version": "$version"
+ }
+ }
+ },
+ {
+ "name": "square_search_team_members",
+ "description": "Search team members by location, status or owner flag: id, names, email, phone, status, is_owner and assigned locations. Read-only, although it is a POST.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location_ids": {
+ "type": "array",
+ "description": "Members assigned to any of these locations.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "status": {
+ "type": "string",
+ "description": "ACTIVE or INACTIVE.",
+ "enum": [
+ "ACTIVE",
+ "INACTIVE"
+ ]
+ },
+ "is_owner": {
+ "type": "boolean",
+ "description": "true for the account owner only."
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Members per page, 1 to 200.",
+ "minimum": 1,
+ "maximum": 200
+ },
+ "cursor": {
+ "type": "string",
+ "description": "cursor from the previous response, to fetch the next page."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v2/team-members/search",
+ "bodyMapping": {
+ "query": {
+ "filter": {
+ "location_ids": "$location_ids",
+ "status": "$status",
+ "is_owner": "$is_owner"
+ }
+ },
+ "limit": "$limit",
+ "cursor": "$cursor"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "square_get_team_member",
+ "description": "Read one team member: names, email, phone, status, owner flag and location assignments.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "team_member_id": {
+ "type": "string",
+ "description": "The team member id."
+ }
+ },
+ "required": [
+ "team_member_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v2/team-members/{team_member_id}",
+ "encodePathParams": true
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/square.live.spec.ts b/packages/backend/src/adapters/intl/square.live.spec.ts
new file mode 100644
index 000000000..8a84494d5
--- /dev/null
+++ b/packages/backend/src/adapters/intl/square.live.spec.ts
@@ -0,0 +1,489 @@
+import axios from 'axios';
+import { randomUUID } from 'node:crypto';
+import * as adapter from './square.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { interpolateConnectorConfig } from '../../common/env-interpolation.util';
+import { describeAdapterEnvVars } from '../env-var-meta';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the Square adapter:
+ *
+ * 1. Static: always runs. Pins the bearer token, the Square-Version header,
+ * the environment variable patterns, every tool's method and URL, the
+ * bodies of searches, refunds, orders and invoices, and which tools are
+ * read-only, destructive or switched off.
+ *
+ * 2. Live: skipped unless SQUARE_ACCESS_TOKEN is set. Uses the sandbox
+ * unless SQUARE_API_URL says otherwise (the token must match):
+ *
+ * SQUARE_ACCESS_TOKEN=EAAA... npx jest src/adapters/intl/square.live.spec.ts
+ *
+ * Reads the merchant, locations, payments, refunds, orders, customers,
+ * catalog, inventory, invoices and team members.
+ *
+ * SQUARE_LIVE_WRITE=1 (sandbox only) adds three round-trips, each
+ * cleaned up in `finally`: a customer created, read, updated, found and
+ * deleted; a catalog item created, read, searched, its inventory read
+ * and deleted; an order and a draft invoice for the test customer that
+ * is published and cancelled (deleted if publishing failed). A sandbox
+ * payment of 1.00 made with Square's test card nonce is read and
+ * refunded by 0.50.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const SANDBOX = 'https://connect.squareupsandbox.com';
+const execute = (name: string, env: Record, params: Record) => {
+ const { config, endpointMapping } = interpolateConnectorConfig(
+ { baseUrl: a.connector.baseUrl, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ env,
+ );
+ return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService).execute(
+ {
+ baseUrl: config.baseUrl,
+ authType: a.connector.authType,
+ authConfig: { token: env.SQUARE_ACCESS_TOKEN },
+ headers: config.headers,
+ },
+ endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ ) as Promise;
+};
+const call = (name: string, params: Record) =>
+ execute(name, { SQUARE_API_URL: SANDBOX, SQUARE_ACCESS_TOKEN: 'test-token' }, params);
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('square adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 11 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/sandbox/);
+ });
+
+ it('sends the access token as Bearer and pins a Square-Version', () => {
+ expect(a.connector.authType).toBe('BEARER_TOKEN');
+ expect(a.connector.authConfig).toEqual({ token: '{{SQUARE_ACCESS_TOKEN}}' });
+ expect(a.connector.baseUrl).toBe('{{SQUARE_API_URL}}');
+ expect(a.connector.headers['Square-Version']).toMatch(/^20\d\d-\d\d-\d\d$/);
+ expect(a.connector.headers['Square-Version']).toBe('2026-09-16');
+ expect(a.instructions).toContain(a.connector.headers['Square-Version']);
+ });
+
+ it('validates the environment URL and marks the token as secret', () => {
+ expect(a.requiredEnvVars).toEqual(['SQUARE_ACCESS_TOKEN', 'SQUARE_API_URL']);
+ const env = new RegExp(a.envVarMeta.SQUARE_API_URL.pattern!);
+ expect(env.test(SANDBOX)).toBe(true);
+ expect(env.test('https://connect.squareup.com')).toBe(true);
+ expect(env.test(`${SANDBOX}/`)).toBe(false);
+ expect(env.test('https://connect.squareup.com.evil.example')).toBe(false);
+ const described = describeAdapterEnvVars(a as never);
+ expect(described.find((d) => d.name === 'SQUARE_API_URL')!.kind).toBe('address');
+ expect(described.find((d) => d.name === 'SQUARE_ACCESS_TOKEN')!.secret).toBe(true);
+ });
+
+ it('probes and health-checks with the merchant behind the token', () => {
+ expect(a.probe.tool).toBe('square_get_merchant');
+ expect(tool('square_get_merchant').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/v2/merchants/me');
+ });
+
+ it('prefixes every tool with square_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^square_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('marks reads read-only, including the POST searches and the inventory batch read', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual([
+ 'square_get_catalog_object',
+ 'square_get_customer',
+ 'square_get_inventory_count',
+ 'square_get_inventory_counts',
+ 'square_get_invoice',
+ 'square_get_location',
+ 'square_get_merchant',
+ 'square_get_order',
+ 'square_get_payment',
+ 'square_get_refund',
+ 'square_get_team_member',
+ 'square_list_catalog',
+ 'square_list_customers',
+ 'square_list_invoices',
+ 'square_list_locations',
+ 'square_list_payments',
+ 'square_list_refunds',
+ 'square_search_catalog_items',
+ 'square_search_catalog_objects',
+ 'square_search_customers',
+ 'square_search_invoices',
+ 'square_search_orders',
+ 'square_search_team_members',
+ ]);
+ });
+
+ it('marks refunds, cancellations and catalog replacement destructive, and installs deletes switched off', () => {
+ for (const name of ['square_refund_payment', 'square_cancel_invoice', 'square_upsert_catalog_object']) {
+ const ann = annotationsOf(tool(name));
+ expect(`${name}:${ann.readOnlyHint}:${ann.destructiveHint}:${ann.idempotentHint}`).toBe(`${name}:false:true:false`);
+ expect(tool(name).description).toMatch(/confirm/i);
+ }
+ expect(tool('square_publish_invoice').description).toMatch(/confirm/i);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual([
+ 'square_delete_catalog_object',
+ 'square_delete_customer',
+ 'square_delete_invoice',
+ ]);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bsquare_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bsquare_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(10);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ const table: Array<[string, Record, string, string]> = [
+ ['square_get_merchant', {}, 'GET', '/v2/merchants/me'],
+ ['square_list_locations', {}, 'GET', '/v2/locations'],
+ ['square_get_location', { location_id: 'main' }, 'GET', '/v2/locations/main'],
+ ['square_list_payments', {}, 'GET', '/v2/payments'],
+ ['square_get_payment', { payment_id: 'P1' }, 'GET', '/v2/payments/P1'],
+ ['square_list_refunds', {}, 'GET', '/v2/refunds'],
+ ['square_get_refund', { refund_id: 'R1' }, 'GET', '/v2/refunds/R1'],
+ ['square_refund_payment', { payment_id: 'P1', amount_money: { amount: 100, currency: 'EUR' }, idempotency_key: 'k' }, 'POST', '/v2/refunds'],
+ ['square_search_orders', { location_ids: ['L1'] }, 'POST', '/v2/orders/search'],
+ ['square_get_order', { order_id: 'O1' }, 'GET', '/v2/orders/O1'],
+ ['square_create_order', { location_id: 'L1', line_items: [], idempotency_key: 'k' }, 'POST', '/v2/orders'],
+ ['square_list_customers', {}, 'GET', '/v2/customers'],
+ ['square_search_customers', {}, 'POST', '/v2/customers/search'],
+ ['square_get_customer', { customer_id: 'C1' }, 'GET', '/v2/customers/C1'],
+ ['square_create_customer', { given_name: 'Ana' }, 'POST', '/v2/customers'],
+ ['square_update_customer', { customer_id: 'C1', note: 'x' }, 'PUT', '/v2/customers/C1'],
+ ['square_delete_customer', { customer_id: 'C1' }, 'DELETE', '/v2/customers/C1'],
+ ['square_list_catalog', {}, 'GET', '/v2/catalog/list'],
+ ['square_search_catalog_items', {}, 'POST', '/v2/catalog/search-catalog-items'],
+ ['square_search_catalog_objects', {}, 'POST', '/v2/catalog/search'],
+ ['square_get_catalog_object', { object_id: 'OBJ1' }, 'GET', '/v2/catalog/object/OBJ1'],
+ ['square_upsert_catalog_object', { object: {}, idempotency_key: 'k' }, 'POST', '/v2/catalog/object'],
+ ['square_delete_catalog_object', { object_id: 'OBJ1' }, 'DELETE', '/v2/catalog/object/OBJ1'],
+ ['square_get_inventory_counts', {}, 'POST', '/v2/inventory/counts/batch-retrieve'],
+ ['square_get_inventory_count', { catalog_object_id: 'V1' }, 'GET', '/v2/inventory/V1'],
+ ['square_list_invoices', { location_id: 'L1' }, 'GET', '/v2/invoices'],
+ ['square_search_invoices', { location_id: 'L1' }, 'POST', '/v2/invoices/search'],
+ ['square_get_invoice', { invoice_id: 'inv:1' }, 'GET', '/v2/invoices/inv%3A1'],
+ ['square_create_invoice', { location_id: 'L1', order_id: 'O1', customer_id: 'C1', payment_requests: [], idempotency_key: 'k' }, 'POST', '/v2/invoices'],
+ ['square_publish_invoice', { invoice_id: 'inv:1', version: 0 }, 'POST', '/v2/invoices/inv%3A1/publish'],
+ ['square_cancel_invoice', { invoice_id: 'inv:1', version: 1 }, 'POST', '/v2/invoices/inv%3A1/cancel'],
+ ['square_delete_invoice', { invoice_id: 'inv:1' }, 'DELETE', '/v2/invoices/inv%3A1'],
+ ['square_search_team_members', {}, 'POST', '/v2/team-members/search'],
+ ['square_get_team_member', { team_member_id: 'T1' }, 'GET', '/v2/team-members/T1'],
+ ];
+
+ it('lists every tool in the URL table', () => {
+ expect(table.map(([name]) => name).sort()).toEqual(a.tools.map((t) => t.name).sort());
+ });
+
+ it.each(table)('%s sends %s to the right URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method,
+ url: `${SANDBOX}${path}`,
+ headers: expect.objectContaining({ Authorization: 'Bearer test-token', 'Square-Version': '2026-09-16' }),
+ }),
+ );
+ });
+
+ it('refunds with the idempotency key, payment and amount', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('square_refund_payment', { payment_id: 'P1', amount_money: { amount: 50, currency: 'EUR' }, reason: 'Damaged', idempotency_key: 'k1' });
+ expect(sent().data).toEqual({ idempotency_key: 'k1', payment_id: 'P1', amount_money: { amount: 50, currency: 'EUR' }, reason: 'Damaged' });
+ });
+
+ it('wraps the order fields in an order object', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ const items = [{ name: 'Repair', quantity: '1', base_price_money: { amount: 4500, currency: 'EUR' } }];
+ await call('square_create_order', { location_id: 'L1', line_items: items, customer_id: 'C1', idempotency_key: 'k2' });
+ expect(sent().data).toEqual({ order: { location_id: 'L1', line_items: items, customer_id: 'C1' }, idempotency_key: 'k2' });
+ });
+
+ it('builds the invoice with the recipient, card payments by default and EMAIL delivery', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ const requests = [{ request_type: 'BALANCE', due_date: '2026-11-01' }];
+ await call('square_create_invoice', { location_id: 'L1', order_id: 'O1', customer_id: 'C1', payment_requests: requests, title: 'Repair', idempotency_key: 'k3' });
+ expect(sent().data).toEqual({
+ invoice: {
+ location_id: 'L1',
+ order_id: 'O1',
+ primary_recipient: { customer_id: 'C1' },
+ payment_requests: requests,
+ accepted_payment_methods: { card: true },
+ delivery_method: 'EMAIL',
+ title: 'Repair',
+ },
+ idempotency_key: 'k3',
+ });
+ });
+
+ it('searches invoices of one location and, when given, one customer', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('square_search_invoices', { location_id: 'L1' });
+ expect(sent().data).toEqual({ query: { filter: { location_ids: ['L1'] }, sort: { field: 'INVOICE_SORT_DATE', order: 'DESC' } } });
+ mockedAxios.mockClear();
+ await call('square_search_invoices', { location_id: 'L1', customer_ids: ['C1'], limit: 10 });
+ expect(sent().data).toEqual({ query: { filter: { location_ids: ['L1'], customer_ids: ['C1'] }, sort: { field: 'INVOICE_SORT_DATE', order: 'DESC' } }, limit: 10 });
+ });
+
+ it('puts the team member filters in query.filter and lists ITEM by default', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('square_search_team_members', { status: 'ACTIVE', location_ids: ['L1'] });
+ expect(sent().data).toEqual({ query: { filter: { location_ids: ['L1'], status: 'ACTIVE' } } });
+ mockedAxios.mockClear();
+ await call('square_list_catalog', {});
+ expect(sent().params).toEqual({ types: 'ITEM' });
+ });
+
+ it('publishes with the version and deletes a customer with an optional version in the query', async () => {
+ mockedAxios.mockResolvedValue({ data: {} });
+ await call('square_publish_invoice', { invoice_id: 'inv:1', version: 0, idempotency_key: 'k4' });
+ expect(sent().data).toEqual({ version: 0, idempotency_key: 'k4' });
+ mockedAxios.mockClear();
+ await call('square_delete_customer', { customer_id: 'C1', version: 3 });
+ expect(sent().params).toEqual({ version: 3 });
+ expect(sent().data).toBeUndefined();
+ });
+});
+
+const TOKEN = process.env.SQUARE_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('square adapter: live API', () => {
+ const host = process.env.SQUARE_API_URL || SANDBOX;
+ const write = process.env.SQUARE_LIVE_WRITE === '1' && host === SANDBOX;
+ beforeAll(() => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ });
+ const run = (name: string, params: Record = {}): Promise =>
+ execute(name, { SQUARE_API_URL: host, SQUARE_ACCESS_TOKEN: TOKEN as string }, params);
+ let locationId = '';
+ let currency = 'USD';
+
+ beforeAll(async () => {
+ const merchant = await run('square_get_merchant');
+ locationId = merchant.merchant.main_location_id;
+ currency = merchant.merchant.currency;
+ }, 30_000);
+
+ it('reads the merchant, locations, payments, refunds and orders', async () => {
+ const locations = await run('square_list_locations');
+ expect(locations.locations.map((l: { id: string }) => l.id)).toContain(locationId);
+ const main = await run('square_get_location', { location_id: 'main' });
+ expect(main.location.id).toBe(locationId);
+ const payments = await run('square_list_payments', { limit: 5, location_id: locationId });
+ if (payments.payments?.length) {
+ const one = await run('square_get_payment', { payment_id: payments.payments[0].id });
+ expect(one.payment.id).toBe(payments.payments[0].id);
+ }
+ const refunds = await run('square_list_refunds', { limit: 5 });
+ if (refunds.refunds?.length) {
+ const one = await run('square_get_refund', { refund_id: refunds.refunds[0].id });
+ expect(one.refund.id).toBe(refunds.refunds[0].id);
+ }
+ const orders = await run('square_search_orders', { location_ids: [locationId], limit: 5 });
+ if (orders.orders?.length) {
+ const one = await run('square_get_order', { order_id: orders.orders[0].id });
+ expect(one.order.id).toBe(orders.orders[0].id);
+ }
+ }, 60_000);
+
+ it('reads customers, the catalog, inventory, invoices and team members', async () => {
+ const customers = await run('square_list_customers', { limit: 5, count: true });
+ if (customers.customers?.length) {
+ const one = await run('square_get_customer', { customer_id: customers.customers[0].id });
+ expect(one.customer.id).toBe(customers.customers[0].id);
+ }
+ await run('square_search_customers', { query: { filter: { created_at: { start_at: '2020-01-01T00:00:00Z' } } }, limit: 5 });
+ const catalog = await run('square_list_catalog', { types: 'ITEM' });
+ const items = await run('square_search_catalog_items', { limit: 5 });
+ expect(items).toBeDefined();
+ await run('square_search_catalog_objects', { object_types: ['ITEM'], limit: 5 });
+ const firstItem = catalog.objects?.[0];
+ if (firstItem) {
+ const one = await run('square_get_catalog_object', { object_id: firstItem.id, include_related_objects: true });
+ expect(one.object.id).toBe(firstItem.id);
+ const variation = firstItem.item_data?.variations?.[0];
+ if (variation) {
+ await run('square_get_inventory_counts', { catalog_object_ids: [variation.id], location_ids: [locationId] });
+ await run('square_get_inventory_count', { catalog_object_id: variation.id, location_ids: locationId });
+ }
+ }
+ const invoices = await run('square_list_invoices', { location_id: locationId, limit: 5 });
+ if (invoices.invoices?.length) {
+ const one = await run('square_get_invoice', { invoice_id: invoices.invoices[0].id });
+ expect(one.invoice.id).toBe(invoices.invoices[0].id);
+ }
+ await run('square_search_invoices', { location_id: locationId, limit: 5 });
+ const team = await run('square_search_team_members', { status: 'ACTIVE', limit: 5 });
+ if (team.team_members?.length) {
+ const one = await run('square_get_team_member', { team_member_id: team.team_members[0].id });
+ expect(one.team_member.id).toBe(team.team_members[0].id);
+ }
+ }, 60_000);
+
+ (write ? it : it.skip)('creates, reads, updates, finds and deletes a customer; invoices them and cancels it', async () => {
+ const created = await run('square_create_customer', {
+ given_name: 'AnythingMCP',
+ family_name: `Test ${Date.now()}`,
+ email_address: 'anythingmcp-test@example.com',
+ note: 'Created by the AnythingMCP live spec; safe to delete.',
+ idempotency_key: randomUUID(),
+ });
+ const customer = created.customer;
+ try {
+ const read = await run('square_get_customer', { customer_id: customer.id });
+ expect(read.customer.family_name).toBe(customer.family_name);
+ const updated = await run('square_update_customer', { customer_id: customer.id, note: 'Updated by the live spec', version: read.customer.version });
+ expect(updated.customer.note).toBe('Updated by the live spec');
+ await run('square_search_customers', { query: { filter: { email_address: { exact: 'anythingmcp-test@example.com' } } } });
+
+ const order = await run('square_create_order', {
+ location_id: locationId,
+ customer_id: customer.id,
+ line_items: [{ name: 'AnythingMCP test line', quantity: '1', base_price_money: { amount: 100, currency } }],
+ idempotency_key: randomUUID(),
+ });
+ const due = new Date(Date.now() + 14 * 86400_000).toISOString().slice(0, 10);
+ const draft = await run('square_create_invoice', {
+ location_id: locationId,
+ order_id: order.order.id,
+ customer_id: customer.id,
+ payment_requests: [{ request_type: 'BALANCE', due_date: due }],
+ title: 'AnythingMCP live spec',
+ idempotency_key: randomUUID(),
+ });
+ let published: { id: string; version: number } | undefined;
+ try {
+ expect(draft.invoice.status).toBe('DRAFT');
+ const res = await run('square_publish_invoice', { invoice_id: draft.invoice.id, version: draft.invoice.version, idempotency_key: randomUUID() });
+ published = res.invoice;
+ expect(res.invoice.status).toMatch(/UNPAID|SCHEDULED|PAYMENT_PENDING/);
+ } finally {
+ if (published) {
+ const cancelled = await run('square_cancel_invoice', { invoice_id: published.id, version: published.version });
+ expect(cancelled.invoice.status).toBe('CANCELED');
+ } else {
+ await run('square_delete_invoice', { invoice_id: draft.invoice.id, version: draft.invoice.version });
+ }
+ }
+ } finally {
+ await run('square_delete_customer', { customer_id: customer.id });
+ }
+ }, 120_000);
+
+ (write ? it : it.skip)('creates a catalog item, reads and searches it, reads its inventory and deletes it', async () => {
+ const name = `AnythingMCP test ${Date.now()}`;
+ const created = await run('square_upsert_catalog_object', {
+ object: {
+ type: 'ITEM',
+ id: '#amcp-item',
+ item_data: {
+ name,
+ variations: [{ type: 'ITEM_VARIATION', id: '#amcp-var', item_variation_data: { item_id: '#amcp-item', name: 'Regular', pricing_type: 'FIXED_PRICING', price_money: { amount: 250, currency } } }],
+ },
+ },
+ idempotency_key: randomUUID(),
+ });
+ const itemId = created.catalog_object.id;
+ try {
+ const read = await run('square_get_catalog_object', { object_id: itemId });
+ expect(read.object.item_data.name).toBe(name);
+ const variationId = read.object.item_data.variations[0].id;
+ await run('square_get_inventory_count', { catalog_object_id: variationId });
+ await run('square_search_catalog_objects', { object_types: ['ITEM'], query: { exact_query: { attribute_name: 'name', attribute_value: name } } });
+ } finally {
+ const deleted = await run('square_delete_catalog_object', { object_id: itemId });
+ expect(deleted.deleted_object_ids).toContain(itemId);
+ }
+ }, 120_000);
+
+ (write ? it : it.skip)('refunds part of a sandbox test payment', async () => {
+ // Created directly: the connector has no tool that takes card payments.
+ const payment = await mockedAxios.__actual.post(
+ `${host}/v2/payments`,
+ { source_id: 'cnon:card-nonce-ok', idempotency_key: randomUUID(), amount_money: { amount: 100, currency }, location_id: locationId, note: 'AnythingMCP live spec' },
+ { headers: { Authorization: `Bearer ${TOKEN}`, 'Square-Version': '2026-09-16', 'Content-Type': 'application/json' } },
+ );
+ const paymentId = payment.data.payment.id;
+ const read = await run('square_get_payment', { payment_id: paymentId });
+ expect(read.payment.status).toBe('COMPLETED');
+ const refund = await run('square_refund_payment', { payment_id: paymentId, amount_money: { amount: 50, currency }, reason: 'AnythingMCP live spec', idempotency_key: randomUUID() });
+ expect(refund.refund.status).toMatch(/PENDING|COMPLETED/);
+ const again = await run('square_get_refund', { refund_id: refund.refund.id });
+ expect(again.refund.payment_id).toBe(paymentId);
+ }, 120_000);
+});
diff --git a/packages/backend/src/adapters/intl/supabase.json b/packages/backend/src/adapters/intl/supabase.json
new file mode 100644
index 000000000..546b2b79a
--- /dev/null
+++ b/packages/backend/src/adapters/intl/supabase.json
@@ -0,0 +1,746 @@
+{
+ "slug": "supabase",
+ "name": "Supabase",
+ "description": "Let your AI look after your Supabase projects: list projects and check their health, explore schemas and tables, run read-only SQL, read logs, security and performance advisors, Edge Functions, migrations, branches and storage buckets. Through the Management API with a personal access token.",
+ "instructions": "## Supabase\nThis connector works with your Supabase projects through the **Management API** (`https://api.supabase.com`, v1): projects and their health, the database (read-only SQL, schemas, tables with columns, constraints, indexes and policies, extensions, migrations, backups, TypeScript types), logs, security and performance advisors, Edge Functions, API keys (without secrets), preview branches and Storage buckets. It is the view of a project owner, not of an app user: it does not go through your project's REST API or row level security.\n\n**Setup (about 2 minutes)**\n1. Open https://supabase.com/dashboard/account/tokens (account menu → **Account preferences** → **Access Tokens**) and click **Generate new token**.\n2. Prefer a **scoped token**: pick the organization and projects the AI may see and give each capability Read (or Read-write where it should change something). For the tools here: Projects, Project Settings, Database, Migrations, Backups, Edge Functions, API Keys (not API Key Secrets), Logs, Usage Analytics, Advisors, Storage, Development Branches and Organizations. A legacy token has your full access to every organization instead.\n3. Set an expiry (at most one year), copy the token (it starts with `sbp_`; Supabase shows it only once) and paste it into `SUPABASE_ACCESS_TOKEN`.\n4. A token's access cannot be changed later: to widen or narrow it, create a new one and delete the old.\n\n**Read-only setup**: give a scoped token Read on every capability. `supabase_execute_sql` is installed switched off; with Database Read only, even that tool runs its SQL read-only. Nothing else here changes data.\n\n**Projects**: every tool takes `project_ref`, the 20 lowercase letters in `https://[.supabase.co` and in `supabase_list_projects`. Free projects pause after a week without activity and then show status `INACTIVE`: database tools fail until the project is restored in the dashboard. Check `supabase_get_project_health` when an app reports errors.\n\n**SQL**\n- `supabase_run_read_only_sql` runs one statement as a read-only database user. Qualify every table with its schema (`public.orders`, not `orders`), pass values as `$1`, `$2` with `parameters`, and add a `LIMIT`: results over 200 KB are cut and marked `_truncated`.\n- Start with `supabase_list_schemas`, `supabase_list_tables` (row level security on or off, estimated rows, size) and `supabase_describe_table` (columns, keys, indexes, policies).\n- `supabase_execute_sql` (switched off at install) runs as `postgres` with full rights: DDL, writes and grants apply immediately and cannot be undone. Turn it on only for a user who wants the AI to change the database, and have every statement confirmed. For schema changes that should live in version control, the user's own migration workflow is the better place.\n- The database query endpoints are marked beta by Supabase. A failing statement comes back with Postgres' message, e.g. \"Failed to run sql query: relation \\\"orders\\\" does not exist\".\n\n**Logs**: `supabase_query_logs` takes SQL in the ClickHouse dialect over one `logs` table. Filter on `source` (`edge_logs` for API requests, `postgres_logs`, `auth_logs`, `function_logs` and `function_edge_logs` for Edge Functions, `storage_logs`, `realtime_logs`) and read nested fields with `log_attributes['request.path']` or `log_attributes['response.status_code']`. Without `iso_timestamp_start` and `iso_timestamp_end` only the last minute is searched; a range may span at most 24 hours and must lie within the plan's retention (1 day on Free, 7 on Pro, 28 on Team). Example: `select timestamp, event_message from logs where source = 'postgres_logs' order by timestamp desc limit 50`.\n\n**Advisors**: `supabase_get_security_advisors` and `supabase_get_performance_advisors` run Supabase's database linter, the same checks as the dashboard's Advisors page. Each finding has a level (ERROR, WARN, INFO), the affected object and a remediation link; fix ERROR ones first, especially tables exposed without row level security.\n\n**API keys**: `supabase_list_api_keys` names every key but returns the value only of public ones (publishable keys and the legacy anon key). Secret keys and the legacy service_role key are listed without their value on purpose: they bypass row level security and must never travel through a chat. There is no tool to reveal them.\n\n**Edge Functions**: `supabase_list_edge_functions` and `supabase_get_edge_function` give status, version and settings. Function code is not downloaded; read it from the user's repository. For errors, query `function_logs` with `supabase_query_logs`.\n\n**Branches**: preview branches need a paid plan. `supabase_list_branches` lists them; each branch has its own `project_ref`, which the other tools accept.\n\n**Rate limits**: 120 requests per minute per user and endpoint (all of a user's tokens share it), 30 per minute for logs and usage. Over it Supabase answers 429 for the rest of the minute.\n\n**Errors**: 401 \"Invalid access token\" means the token is wrong, expired or deleted. 403 means the token may not do this: scoped tokens list what is missing in `missing_permissions`. 404 is an unknown project ref, or a project outside the token's organizations. 402 on logs means the organization's usage quota is exhausted. 410 Gone would mean an endpoint Supabase has retired.\n\n**Out of scope here**: creating, pausing, restoring or deleting projects, deploying Edge Functions, secrets and auth configuration, custom domains, network restrictions, billing, and data in your app's tables through PostgREST.",
+ "region": "intl",
+ "category": "database",
+ "icon": "supabase",
+ "docsUrl": "https://supabase.com/docs/reference/api/introduction",
+ "requiredEnvVars": [
+ "SUPABASE_ACCESS_TOKEN"
+ ],
+ "envVarMeta": {
+ "SUPABASE_ACCESS_TOKEN": {
+ "label": "Personal access token",
+ "kind": "credential",
+ "secret": true,
+ "help": "Supabase dashboard → account menu → Account preferences → Access Tokens → Generate new token. A scoped token can be limited to some projects and to read access.",
+ "link": "https://supabase.com/dashboard/account/tokens",
+ "pattern": "^sbp_\\S+$",
+ "patternMessage": "Paste the token as Supabase shows it, starting with sbp_, without the word Bearer."
+ }
+ },
+ "verifyHints": {
+ "401": "Supabase refused the token: it is wrong, expired or was deleted. Create a new one under Account preferences → Access Tokens.",
+ "403": "The token may not do this. A scoped token lists what it lacks in missing_permissions; create a new token that includes it (access cannot be changed afterwards).",
+ "rate_limited": "Supabase allows 120 Management API requests per minute per user (30 for logs). Wait a minute and try again."
+ },
+ "probe": {
+ "tool": "supabase_list_projects"
+ },
+ "prerequisites": "A Supabase account with at least one project (the Free plan works) and a personal access token created under Account preferences → Access Tokens. About 2 minutes.",
+ "connector": {
+ "name": "Supabase Management API",
+ "type": "REST",
+ "baseUrl": "https://api.supabase.com",
+ "authType": "BEARER_TOKEN",
+ "authConfig": {
+ "token": "{{SUPABASE_ACCESS_TOKEN}}"
+ },
+ "headers": {
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/v1/projects"
+ },
+ "tools": [
+ {
+ "name": "supabase_list_projects",
+ "description": "List every project the token can reach: ref, name, organization_slug, region, status (ACTIVE_HEALTHY, INACTIVE when paused, COMING_UP, RESTORING...), created_at and the Postgres host and version. The ref is what every other tool takes.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects"
+ }
+ },
+ {
+ "name": "supabase_list_organizations",
+ "description": "List the organizations the token can reach, with their slug and name.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/organizations"
+ }
+ },
+ {
+ "name": "supabase_get_organization",
+ "description": "Get one organization by slug, including its plan (free, pro, team, enterprise).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "organization_slug": {
+ "type": "string",
+ "description": "Organization slug, from supabase_list_organizations or supabase_list_projects."
+ }
+ },
+ "required": [
+ "organization_slug"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/organizations/{organization_slug}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_get_project",
+ "description": "Get one project: name, ref, organization, region, status, created_at and database host and Postgres version. A paused free project shows status INACTIVE; restore it in the dashboard before querying it.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_get_project_health",
+ "description": "Check the health of a project's services: for each one name, status (ACTIVE_HEALTHY, COMING_UP, UNHEALTHY) and error or info. Use it when an app reports errors or right after a restore.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "services": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "auth",
+ "db",
+ "db_postgres_user",
+ "pooler",
+ "realtime",
+ "rest",
+ "storage",
+ "pg_bouncer"
+ ]
+ },
+ "default": [
+ "db",
+ "auth",
+ "rest",
+ "realtime",
+ "storage"
+ ],
+ "description": "Services to check."
+ },
+ "timeout_ms": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 10000,
+ "description": "How long to wait for each check, 0 to 10000 milliseconds."
+ }
+ },
+ "required": [
+ "project_ref",
+ "services"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/health",
+ "encodePathParams": true,
+ "queryParams": {
+ "services": "$services",
+ "timeout_ms": "$timeout_ms"
+ }
+ }
+ },
+ {
+ "name": "supabase_run_read_only_sql",
+ "description": "Run one SQL statement as the read-only database user and return the rows as JSON objects. Writes are refused. Qualify every table with its schema (public.orders, not orders). Use $1, $2 placeholders with parameters for values instead of pasting them into the text. Add a LIMIT: large results are cut at 200 KB.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "query": {
+ "type": "string",
+ "description": "One SQL statement, e.g. select id, status from public.orders where created_at > $1 order by created_at desc limit 50."
+ },
+ "parameters": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Values for $1, $2 ... in order."
+ }
+ },
+ "required": [
+ "project_ref",
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/projects/{project_ref}/database/query/read-only",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "query": "$query",
+ "parameters": "$parameters"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "supabase_execute_sql",
+ "enabled": false,
+ "description": "Run SQL with full rights (as postgres): DDL, inserts, updates, deletes, grants. Changes take effect at once and cannot be undone. Show the user the exact statement and wait for explicit confirmation; for schema changes prefer a migration in the user's own workflow. Switched off at install.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "query": {
+ "type": "string",
+ "description": "SQL to run; several statements separated by semicolons are run together."
+ },
+ "parameters": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Values for $1, $2 ... in order."
+ },
+ "read_only": {
+ "type": "boolean",
+ "description": "true to run it read-only after all."
+ }
+ },
+ "required": [
+ "project_ref",
+ "query"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/projects/{project_ref}/database/query",
+ "encodePathParams": true,
+ "bodyMapping": {
+ "query": "$query",
+ "parameters": "$parameters",
+ "read_only": "$read_only"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "supabase_list_schemas",
+ "description": "List the database's schemas with their owner and number of tables, leaving out Postgres' own (pg_*, information_schema). Supabase keeps app data in public and its own services in auth, storage, realtime, graphql, extensions and vault.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/projects/{project_ref}/database/query/read-only",
+ "bodyMapping": {
+ "query": "select n.nspname as schema, pg_catalog.pg_get_userbyid(n.nspowner) as owner, (select count(*) from pg_catalog.pg_class c where c.relnamespace = n.oid and c.relkind in ('r', 'p')) as tables from pg_catalog.pg_namespace n where n.nspname not like 'pg\\_%' and n.nspname <> 'information_schema' order by n.nspname"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "supabase_list_tables",
+ "description": "List the tables, views and materialized views of one or more schemas: schema, name, kind, whether row level security is on, estimated row count, total size and comment. Tables in public without RLS are readable by anyone with the anon key.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "schemas": {
+ "type": "string",
+ "default": "public",
+ "description": "Comma-separated schemas, e.g. public or public,auth (default public)."
+ }
+ },
+ "required": [
+ "project_ref",
+ "schemas"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/projects/{project_ref}/database/query/read-only",
+ "bodyMapping": {
+ "query": "select n.nspname as schema, c.relname as name, case c.relkind when 'r' then 'table' when 'p' then 'partitioned table' when 'v' then 'view' when 'm' then 'materialized view' when 'f' then 'foreign table' end as kind, c.relrowsecurity as rls_enabled, greatest(c.reltuples, 0)::bigint as estimated_rows, pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(c.oid)) as total_size, pg_catalog.obj_description(c.oid, 'pg_class') as comment from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where c.relkind in ('r', 'p', 'v', 'm', 'f') and n.nspname = any(pg_catalog.string_to_array($1, ',')) order by n.nspname, c.relname",
+ "parameters": [
+ "$schemas"
+ ]
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "supabase_describe_table",
+ "description": "Describe one table or view: columns (name, type, nullable, default, comment), constraints (primary key, foreign keys, unique, check, with their definition), indexes and row level security policies.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "schema": {
+ "type": "string",
+ "default": "public",
+ "description": "Schema of the table (default public)."
+ },
+ "table": {
+ "type": "string",
+ "description": "Table or view name, exactly as stored (case-sensitive)."
+ }
+ },
+ "required": [
+ "project_ref",
+ "schema",
+ "table"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/projects/{project_ref}/database/query/read-only",
+ "bodyMapping": {
+ "query": "select (select coalesce(json_agg(json_build_object('name', a.attname, 'type', pg_catalog.format_type(a.atttypid, a.atttypmod), 'nullable', not a.attnotnull, 'default', pg_catalog.pg_get_expr(d.adbin, d.adrelid), 'comment', pg_catalog.col_description(a.attrelid, a.attnum)) order by a.attnum), '[]') from pg_catalog.pg_attribute a left join pg_catalog.pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum where a.attrelid = (select c.oid from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2) and a.attnum > 0 and not a.attisdropped) as columns, (select coalesce(json_agg(json_build_object('name', con.conname, 'type', case con.contype when 'p' then 'primary key' when 'f' then 'foreign key' when 'u' then 'unique' when 'c' then 'check' when 'x' then 'exclusion' else con.contype::text end, 'definition', pg_catalog.pg_get_constraintdef(con.oid))), '[]') from pg_catalog.pg_constraint con where con.conrelid = (select c.oid from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relname = $2)) as constraints, (select coalesce(json_agg(json_build_object('name', i.indexname, 'definition', i.indexdef)), '[]') from pg_catalog.pg_indexes i where i.schemaname = $1 and i.tablename = $2) as indexes, (select coalesce(json_agg(json_build_object('name', p.policyname, 'command', p.cmd, 'roles', p.roles, 'permissive', p.permissive, 'using', p.qual, 'with_check', p.with_check)), '[]') from pg_catalog.pg_policies p where p.schemaname = $1 and p.tablename = $2) as policies",
+ "parameters": [
+ "$schema",
+ "$table"
+ ]
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "supabase_list_extensions",
+ "description": "List the Postgres extensions installed in the database: name, version and schema (on Supabase usually extensions).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/v1/projects/{project_ref}/database/query/read-only",
+ "bodyMapping": {
+ "query": "select e.extname as name, e.extversion as version, n.nspname as schema from pg_catalog.pg_extension e join pg_catalog.pg_namespace n on n.oid = e.extnamespace order by e.extname"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "supabase_list_migrations",
+ "description": "List the migrations recorded for the database (by the Supabase CLI or the dashboard): version (timestamp) and name, oldest first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/database/migrations",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_list_backups",
+ "description": "List the project's database backups: whether point-in-time recovery is on, and each backup's status and time. Free projects have no downloadable backups.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/database/backups",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_generate_typescript_types",
+ "description": "Generate TypeScript types for the database schema, as the Supabase CLI does: one string with a Database type covering tables, views, functions and enums. Large schemas are cut at 200 KB.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "included_schemas": {
+ "type": "string",
+ "default": "public",
+ "description": "Comma-separated schemas to include (default public)."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/types/typescript",
+ "encodePathParams": true,
+ "queryParams": {
+ "included_schemas": "$included_schemas"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ },
+ {
+ "name": "supabase_list_edge_functions",
+ "description": "List the project's Edge Functions: slug, name, status, version, verify_jwt, entrypoint path and when they were created and updated (epoch milliseconds).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/functions",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_get_edge_function",
+ "description": "Get one Edge Function by slug: status, version, verify_jwt, entrypoint and import map paths, created and updated times. The function's code is not returned.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "function_slug": {
+ "type": "string",
+ "description": "Function slug, e.g. send-welcome-email."
+ }
+ },
+ "required": [
+ "project_ref",
+ "function_slug"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/functions/{function_slug}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_list_api_keys",
+ "description": "List the project's API keys: id, name, type (publishable, secret or legacy), prefix and description. Only public keys (publishable and the legacy anon key) carry their value; secret and service_role keys are listed without it, on purpose.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/api-keys",
+ "encodePathParams": true,
+ "queryParams": {
+ "reveal": "false"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "mode": "jmespath",
+ "expression": "[].{id: id, name: name, type: type, prefix: prefix, description: description, inserted_at: inserted_at, updated_at: updated_at, api_key: (type == 'publishable' || name == 'anon') && api_key || `null`}",
+ "fallbackToRaw": false
+ }
+ }
+ },
+ {
+ "name": "supabase_query_logs",
+ "description": "Query the project's logs with SQL (ClickHouse dialect) over one unified logs table: filter on source (edge_logs, postgres_logs, auth_logs, function_logs, function_edge_logs, storage_logs, realtime_logs), read nested fields with log_attributes['request.path']. Without a time range only the last minute is searched; the range may span at most 24 hours. Returns result rows and, on a bad query, error.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "sql": {
+ "type": "string",
+ "description": "SQL over the logs table, e.g. select timestamp, event_message, log_attributes['response.status_code'] as status from logs where source = 'edge_logs' order by timestamp desc limit 50."
+ },
+ "iso_timestamp_start": {
+ "type": "string",
+ "description": "Start of the range, UTC ISO 8601 ending in Z, e.g. 2026-10-10T08:00:00Z."
+ },
+ "iso_timestamp_end": {
+ "type": "string",
+ "description": "End of the range, UTC ISO 8601 ending in Z; at most 24 hours after the start."
+ }
+ },
+ "required": [
+ "project_ref",
+ "sql"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/analytics/endpoints/logs",
+ "encodePathParams": true,
+ "queryParams": {
+ "sql": "$sql",
+ "iso_timestamp_start": "$iso_timestamp_start",
+ "iso_timestamp_end": "$iso_timestamp_end"
+ }
+ },
+ "responseMapping": {
+ "transform": {
+ "expression": "@",
+ "maxBytes": 200000
+ }
+ }
+ },
+ {
+ "name": "supabase_get_api_usage",
+ "description": "Get request counts per time bucket for the project's REST, Auth, Storage and Realtime APIs over a recent interval.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "interval": {
+ "type": "string",
+ "enum": [
+ "15min",
+ "30min",
+ "1hr",
+ "3hr",
+ "1day",
+ "3day",
+ "7day"
+ ],
+ "default": "1day",
+ "description": "Time window, e.g. 1day (default) or 7day."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/analytics/endpoints/usage.api-counts",
+ "encodePathParams": true,
+ "queryParams": {
+ "interval": "$interval"
+ }
+ }
+ },
+ {
+ "name": "supabase_get_security_advisors",
+ "description": "Run the security advisor (database linter) on the project: findings such as tables without row level security, exposed auth.users, mutable function search paths or insecure extensions, each with level (ERROR, WARN, INFO), detail and a remediation link.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/advisors/security",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_get_performance_advisors",
+ "description": "Run the performance advisor on the project: findings such as unindexed foreign keys, unused or duplicate indexes, RLS policies that re-evaluate auth functions per row, each with level, detail and a remediation link.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/advisors/performance",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_list_branches",
+ "description": "List the project's preview branches (branching, a paid feature): id, name, project_ref of the branch, git_branch, pr_number, is_default, persistent and status. A project without branching answers with an error or an empty list.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/branches",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_get_branch",
+ "description": "Get one preview branch of a project by its name: its own project ref, git branch, pull request number, status and whether it is persistent.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ },
+ "branch_name": {
+ "type": "string",
+ "description": "Branch name, from supabase_list_branches."
+ }
+ },
+ "required": [
+ "project_ref",
+ "branch_name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/branches/{branch_name}",
+ "encodePathParams": true
+ }
+ },
+ {
+ "name": "supabase_list_storage_buckets",
+ "description": "List the project's Storage buckets: id, name, owner, whether public, and created and updated times.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "project_ref": {
+ "type": "string",
+ "description": "Project ref, the 20 lowercase letters in the project's URL (https://][.supabase.co) and in supabase_list_projects."
+ }
+ },
+ "required": [
+ "project_ref"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/v1/projects/{project_ref}/storage/buckets",
+ "encodePathParams": true
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/supabase.live.spec.ts b/packages/backend/src/adapters/intl/supabase.live.spec.ts
new file mode 100644
index 000000000..3e5536684
--- /dev/null
+++ b/packages/backend/src/adapters/intl/supabase.live.spec.ts
@@ -0,0 +1,321 @@
+import * as http from 'node:http';
+import { AddressInfo } from 'node:net';
+import * as adapterJson from './supabase.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applyResponseTransform } from '../../connectors/response-transform.util';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Static checks and request-shape checks against a local server always run.
+ * The live block runs every read tool through the real RestEngine against the
+ * Supabase Management API:
+ * SUPABASE_ACCESS_TOKEN=sbp_... [SUPABASE_PROJECT_REF=abcdefghijklmnopqrst] \
+ * npx jest src/adapters/intl/supabase.live.spec.ts
+ * Without SUPABASE_PROJECT_REF the first ACTIVE_HEALTHY project is used.
+ * SUPABASE_LIVE_WRITE=1 (needs SUPABASE_PROJECT_REF, a throwaway project)
+ * also creates a table public.anythingmcp_test_] with supabase_execute_sql,
+ * inserts and updates a row, reads it back read-only, and drops the table in
+ * `finally`.
+ */
+
+type Mapping = { method: string; path: string; encodePathParams?: boolean; queryParams?: Record; bodyMapping?: Record };
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ annotations?: Record;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: Mapping;
+ responseMapping?: Record;
+};
+const a = adapterJson as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string };
+ connector: { baseUrl: string; authType: string; authConfig: Record; headers: Record; healthcheckPath: string };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+const engine = () => new RestEngine({} as OAuth2TokenService, {} as LoginTokenService);
+const SQL_READS = ['supabase_describe_table', 'supabase_list_extensions', 'supabase_list_schemas', 'supabase_list_tables', 'supabase_run_read_only_sql'];
+const READ_ONLY_PATH = '/v1/projects/{project_ref}/database/query/read-only';
+
+describe('supabase adapter: static spec conformance', () => {
+ it('is listed (verified live on 11 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ });
+
+ it('calls the Management API with the personal access token as Bearer', () => {
+ expect(a.connector.baseUrl).toBe('https://api.supabase.com');
+ expect(a.connector.authType).toBe('BEARER_TOKEN');
+ expect(a.connector.authConfig).toEqual({ token: '{{SUPABASE_ACCESS_TOKEN}}' });
+ expect(a.requiredEnvVars).toEqual(['SUPABASE_ACCESS_TOKEN']);
+ expect(a.envVarMeta.SUPABASE_ACCESS_TOKEN.secret).toBe(true);
+ const token = new RegExp(a.envVarMeta.SUPABASE_ACCESS_TOKEN.pattern!);
+ expect(token.test('sbp_' + '0'.repeat(40))).toBe(true);
+ expect(token.test('Bearer sbp_0123')).toBe(false);
+ expect(token.test('eyJhbGciOiJIUzI1NiJ9.service_role')).toBe(false);
+ });
+
+ it('probes with the project list, which needs no argument', () => {
+ expect(a.probe.tool).toBe('supabase_list_projects');
+ expect(tool('supabase_list_projects').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/v1/projects');
+ });
+
+ it('prefixes every tool with supabase_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^supabase_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('is read-only except for supabase_execute_sql, which installs switched off and destructive', () => {
+ const writes = a.tools.filter((t) => !annotationsOf(t).readOnlyHint).map((t) => t.name);
+ expect(writes).toEqual(['supabase_execute_sql']);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name)).toEqual(['supabase_execute_sql']);
+ expect(annotationsOf(tool('supabase_execute_sql')).destructiveHint).toBe(true);
+ expect(tool('supabase_execute_sql').description).toMatch(/confirm/i);
+ for (const t of a.tools.filter((x) => x.endpointMapping.method !== 'GET')) {
+ if (t.name === 'supabase_execute_sql') continue;
+ expect(`${t.name} ${t.endpointMapping.method} ${t.endpointMapping.path}`).toBe(`${t.name} POST ${READ_ONLY_PATH}`);
+ }
+ expect(a.tools.filter((t) => t.endpointMapping.path === READ_ONLY_PATH).map((t) => t.name).sort()).toEqual(SQL_READS);
+ });
+
+ it('schema-qualifies every catalog reference in its own SQL, as the read-only endpoint requires', () => {
+ for (const name of SQL_READS.filter((n) => n !== 'supabase_run_read_only_sql')) {
+ const sql = String(tool(name).endpointMapping.bodyMapping!.query);
+ const unqualified = [...sql.matchAll(/\b(from|join)\s+([a-z_]+)(?!\.)\b/gi)].map((m) => m[2]);
+ expect(`${name}: ${unqualified.join(',')}`).toBe(`${name}: `);
+ }
+ });
+
+ it('lists API keys without reveal and keeps only public key values', () => {
+ expect(tool('supabase_list_api_keys').endpointMapping.queryParams).toEqual({ reveal: 'false' });
+ const raw = [
+ { id: '1', name: 'anon', type: 'legacy', api_key: 'eyJ.anon', prefix: null },
+ { id: '2', name: 'service_role', type: 'legacy', api_key: 'eyJ.service', prefix: null },
+ { id: '3', name: 'default', type: 'publishable', api_key: 'sb_publishable_abc', prefix: 'sb_publishable_abc' },
+ { id: '4', name: 'default', type: 'secret', api_key: 'sb_secret_ab········', prefix: 'sb_secret_ab' },
+ ];
+ const out = applyResponseTransform(raw, tool('supabase_list_api_keys').responseMapping).value as Array<{ name: string; api_key: string | null }>;
+ expect(out.map((k) => [k.name, k.api_key])).toEqual([
+ ['anon', 'eyJ.anon'],
+ ['service_role', null],
+ ['default', 'sb_publishable_abc'],
+ ['default', null],
+ ]);
+ // A shape the expression cannot read is an error, never the raw answer.
+ const odd = applyResponseTransform({ unexpected: 'eyJ.service' }, tool('supabase_list_api_keys').responseMapping);
+ expect(odd.value).not.toEqual({ unexpected: 'eyJ.service' });
+ });
+
+ it('only points the model at tools that exist, documents setup and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bsupabase_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bsupabase_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(8);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(a.instructions).toContain('https://supabase.com/dashboard/account/tokens');
+ expect(a.instructions).toContain('Read-only setup');
+ expect(a.instructions).toMatch(/last minute/);
+ expect(JSON.stringify(adapterJson)).not.toMatch(/[–—]/);
+ expect(JSON.stringify(adapterJson)).not.toContain('logs.all');
+ });
+});
+
+describe('supabase adapter: requests through the real engine', () => {
+ let server: http.Server;
+ let origin: string;
+ const seen: Array<{ method?: string; url?: string; headers: http.IncomingHttpHeaders; body: string }> = [];
+
+ beforeAll(async () => {
+ server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body });
+ res.setHeader('Content-Type', 'application/json');
+ res.end('[]');
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ });
+ afterAll(() => new Promise((r) => server.close(() => r())));
+ beforeEach(() => (seen.length = 0));
+
+ const run = (name: string, params: Record) =>
+ engine().execute(
+ { baseUrl: origin, authType: 'BEARER_TOKEN', authConfig: { token: 'sbp_test' }, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ applySchemaDefaults(tool(name).parameters, params),
+ );
+
+ it('runs read-only SQL with bound parameters on the read-only endpoint', async () => {
+ await run('supabase_run_read_only_sql', { project_ref: 'abcdefghijklmnopqrst', query: 'select * from public.orders where id = $1', parameters: [7] });
+ expect(seen[0].method).toBe('POST');
+ expect(seen[0].url).toBe('/v1/projects/abcdefghijklmnopqrst/database/query/read-only');
+ expect(seen[0].headers.authorization).toBe('Bearer sbp_test');
+ expect(JSON.parse(seen[0].body)).toEqual({ query: 'select * from public.orders where id = $1', parameters: [7] });
+ });
+
+ it('lists tables of the given schemas as one bound parameter, public by default', async () => {
+ await run('supabase_list_tables', { project_ref: 'abcdefghijklmnopqrst' });
+ const sent = JSON.parse(seen[0].body);
+ expect(sent.parameters).toEqual(['public']);
+ expect(sent.query).toContain('string_to_array($1');
+ await run('supabase_describe_table', { project_ref: 'abcdefghijklmnopqrst', table: 'Orders' });
+ expect(JSON.parse(seen[1].body).parameters).toEqual(['public', 'Orders']);
+ });
+
+ it('checks health of the default services as repeated keys', async () => {
+ await run('supabase_get_project_health', { project_ref: 'abcdefghijklmnopqrst' });
+ const url = new URL(seen[0].url!, origin);
+ expect(url.pathname).toBe('/v1/projects/abcdefghijklmnopqrst/health');
+ expect(url.searchParams.getAll('services')).toEqual(['db', 'auth', 'rest', 'realtime', 'storage']);
+ });
+
+ it('queries logs on the current endpoint with the SQL and the range', async () => {
+ await run('supabase_query_logs', { project_ref: 'abcdefghijklmnopqrst', sql: "select timestamp from logs where source = 'edge_logs' limit 5", iso_timestamp_start: '2026-10-10T08:00:00Z', iso_timestamp_end: '2026-10-10T09:00:00Z' });
+ const url = new URL(seen[0].url!, origin);
+ expect(url.pathname).toBe('/v1/projects/abcdefghijklmnopqrst/analytics/endpoints/logs');
+ expect(url.searchParams.get('sql')).toBe("select timestamp from logs where source = 'edge_logs' limit 5");
+ expect(url.searchParams.get('iso_timestamp_start')).toBe('2026-10-10T08:00:00Z');
+ });
+
+ it('asks for API keys with reveal=false', async () => {
+ await run('supabase_list_api_keys', { project_ref: 'abcdefghijklmnopqrst' });
+ expect(seen[0].url).toBe('/v1/projects/abcdefghijklmnopqrst/api-keys?reveal=false');
+ });
+});
+
+const TOKEN = process.env.SUPABASE_ACCESS_TOKEN;
+const live = TOKEN ? describe : describe.skip;
+
+live('supabase adapter: live Management API', () => {
+ const config = () => ({ baseUrl: a.connector.baseUrl, authType: 'BEARER_TOKEN', authConfig: { token: TOKEN as string }, headers: a.connector.headers });
+ const run = (name: string, params: Record = {}): Promise =>
+ engine().execute(config(), tool(name).endpointMapping, applySchemaDefaults(tool(name).parameters, params));
+ const status = (e: any) => e?.response?.status;
+
+ let ref = process.env.SUPABASE_PROJECT_REF;
+
+ beforeAll(async () => {
+ if (ref) return;
+ const projects = await run('supabase_list_projects');
+ ref = projects.find((p: { status: string }) => p.status === 'ACTIVE_HEALTHY')?.ref;
+ }, 30000);
+
+ it('reads projects, organizations, the project and its health', async () => {
+ const projects = await run('supabase_list_projects');
+ expect(Array.isArray(projects)).toBe(true);
+ const orgs = await run('supabase_list_organizations');
+ expect(Array.isArray(orgs)).toBe(true);
+ if (orgs[0]) expect((await run('supabase_get_organization', { organization_slug: orgs[0].slug })).name).toBeDefined();
+ expect(ref).toBeDefined();
+ expect((await run('supabase_get_project', { project_ref: ref })).ref).toBe(ref);
+ const health = await run('supabase_get_project_health', { project_ref: ref });
+ expect(health.map((s: { name: string }) => s.name).sort()).toEqual(['auth', 'db', 'realtime', 'rest', 'storage']);
+ }, 60000);
+
+ it('runs read-only SQL, lists schemas, tables, a table, extensions, migrations, backups and types', async () => {
+ const rows = await run('supabase_run_read_only_sql', { project_ref: ref, query: 'select $1::int + 1 as two', parameters: [1] });
+ expect(rows).toEqual([{ two: 2 }]);
+ await expect(run('supabase_run_read_only_sql', { project_ref: ref, query: 'create table public.anythingmcp_never (id int)' })).rejects.toBeDefined();
+ const schemas = await run('supabase_list_schemas', { project_ref: ref });
+ expect(schemas.map((s: { schema: string }) => s.schema)).toEqual(expect.arrayContaining(['public', 'auth']));
+ const tables = await run('supabase_list_tables', { project_ref: ref, schemas: 'public,auth' });
+ expect(tables.some((t: { schema: string; name: string }) => t.schema === 'auth' && t.name === 'users')).toBe(true);
+ const users = await run('supabase_describe_table', { project_ref: ref, schema: 'auth', table: 'users' });
+ expect(users[0].columns.some((c: { name: string }) => c.name === 'email')).toBe(true);
+ expect(users[0].constraints.some((c: { type: string }) => c.type === 'primary key')).toBe(true);
+ expect((await run('supabase_list_extensions', { project_ref: ref })).some((e: { name: string }) => e.name === 'plpgsql')).toBe(true);
+ expect(Array.isArray(await run('supabase_list_migrations', { project_ref: ref }))).toBe(true);
+ expect(await run('supabase_list_backups', { project_ref: ref })).toHaveProperty('backups');
+ expect(typeof (await run('supabase_generate_typescript_types', { project_ref: ref })).types).toBe('string');
+ }, 90000);
+
+ it('reads Edge Functions, storage buckets and API keys without secret values', async () => {
+ const fns = await run('supabase_list_edge_functions', { project_ref: ref });
+ expect(Array.isArray(fns)).toBe(true);
+ if (fns[0]) expect((await run('supabase_get_edge_function', { project_ref: ref, function_slug: fns[0].slug })).slug).toBe(fns[0].slug);
+ expect(Array.isArray(await run('supabase_list_storage_buckets', { project_ref: ref }))).toBe(true);
+ const raw = await run('supabase_list_api_keys', { project_ref: ref });
+ const shaped = applyResponseTransform(raw, tool('supabase_list_api_keys').responseMapping);
+ expect(shaped.applied).toBe(true);
+ for (const k of shaped.value as Array<{ name: string; type: string; api_key: string | null }>) {
+ if (k.type !== 'publishable' && k.name !== 'anon') expect(k.api_key).toBeNull();
+ }
+ }, 60000);
+
+ it('queries logs and API usage, and runs both advisors', async () => {
+ const end = new Date();
+ const start = new Date(end.getTime() - 60 * 60 * 1000);
+ const logs = await run('supabase_query_logs', {
+ project_ref: ref,
+ sql: "select timestamp, event_message from logs where source = 'edge_logs' order by timestamp desc limit 5",
+ iso_timestamp_start: start.toISOString().replace(/\.\d+Z$/, 'Z'),
+ iso_timestamp_end: end.toISOString().replace(/\.\d+Z$/, 'Z'),
+ });
+ expect(logs).toHaveProperty('result');
+ expect(await run('supabase_get_api_usage', { project_ref: ref })).toHaveProperty('result');
+ expect(Array.isArray((await run('supabase_get_security_advisors', { project_ref: ref })).lints)).toBe(true);
+ expect(Array.isArray((await run('supabase_get_performance_advisors', { project_ref: ref })).lints)).toBe(true);
+ }, 60000);
+
+ it('lists branches (a paid feature: a free project may refuse)', async () => {
+ const branches = await run('supabase_list_branches', { project_ref: ref }).catch((e) => e);
+ if (Array.isArray(branches)) {
+ if (branches[0]) expect((await run('supabase_get_branch', { project_ref: ref, branch_name: branches[0].name })).name).toBe(branches[0].name);
+ } else {
+ expect([400, 402, 403, 404, 422]).toContain(status(branches));
+ }
+ }, 30000);
+
+ it('a wrong token is a 401', async () => {
+ await expect(
+ engine().execute({ ...config(), authConfig: { token: 'sbp_wrong' } }, tool('supabase_list_projects').endpointMapping, {}),
+ ).rejects.toMatchObject({ response: { status: 401 } });
+ }, 30000);
+
+ (process.env.SUPABASE_LIVE_WRITE === '1' && process.env.SUPABASE_PROJECT_REF ? it : it.skip)(
+ 'creates a test table, writes and changes a row, reads it back, and drops the table',
+ async () => {
+ const table = `anythingmcp_test_${Date.now()}`;
+ let created = false;
+ try {
+ await run('supabase_execute_sql', { project_ref: ref, query: `create table public.${table} (id int primary key, note text)` });
+ created = true;
+ await run('supabase_execute_sql', { project_ref: ref, query: `insert into public.${table} (id, note) values ($1, $2)`, parameters: [1, 'AnythingMCP test'] });
+ await run('supabase_execute_sql', { project_ref: ref, query: `update public.${table} set note = $1 where id = 1`, parameters: ['AnythingMCP test (updated)'] });
+ const rows = await run('supabase_run_read_only_sql', { project_ref: ref, query: `select note from public.${table} where id = 1` });
+ expect(rows).toEqual([{ note: 'AnythingMCP test (updated)' }]);
+ const described = await run('supabase_describe_table', { project_ref: ref, table });
+ expect(described[0].columns.map((c: { name: string }) => c.name)).toEqual(['id', 'note']);
+ } finally {
+ if (created) await run('supabase_execute_sql', { project_ref: ref, query: `drop table if exists public.${table}` });
+ }
+ },
+ 120000,
+ );
+});
diff --git a/packages/backend/src/adapters/intl/zoho-books.json b/packages/backend/src/adapters/intl/zoho-books.json
new file mode 100644
index 000000000..0f09f69a2
--- /dev/null
+++ b/packages/backend/src/adapters/intl/zoho-books.json
@@ -0,0 +1,2185 @@
+{
+ "slug": "zoho-books",
+ "name": "Zoho Books",
+ "description": "Let your AI work in Zoho Books: look up and manage customers, vendors and items, create, email, mark as sent and void invoices, create estimates, record customer payments, bills and expenses, and read the chart of accounts, account ledgers and bank transactions. Any Zoho data center, Zoho OAuth sign-in.",
+ "instructions": "## Zoho Books\nThis connector works with one Zoho Books organization through the **Zoho Books API v3** (`https://www.zohoapis./books/v3`): organizations, contacts (customers and vendors), items, invoices, estimates, customer payments, bills, expenses, the chart of accounts with account transactions, bank accounts and bank transactions, and taxes. It runs as the Zoho user who authorized it, within that user's role.\n\n**Data center first.** Zoho keeps each account in one data center, and the sign-in server and API host must be that data center's:\n\n| Region (your Zoho URL) | `ZOHO_BOOKS_ACCOUNTS_URL` | `ZOHO_BOOKS_API_URL` |\n|---|---|---|\n| EU (books.zoho.eu) | https://accounts.zoho.eu | https://www.zohoapis.eu |\n| US (books.zoho.com) | https://accounts.zoho.com | https://www.zohoapis.com |\n| India (zoho.in) | https://accounts.zoho.in | https://www.zohoapis.in |\n| Australia (zoho.com.au) | https://accounts.zoho.com.au | https://www.zohoapis.com.au |\n| Japan (zoho.jp) | https://accounts.zoho.jp | https://www.zohoapis.jp |\n| Canada (zohocloud.ca) | https://accounts.zohocloud.ca | https://www.zohoapis.ca |\n| Saudi Arabia (zoho.sa) | https://accounts.zoho.sa | https://www.zohoapis.sa |\n| UAE (zoho.ae) | https://accounts.zoho.ae | https://www.zohoapis.ae |\n| China (zoho.com.cn) | https://accounts.zoho.com.cn | https://www.zohoapis.com.cn |\n\nThe address bar when you use Zoho Books tells you the region. Signing in at the wrong one fails with `invalid_client` or an empty organization list.\n\n**Setup (about 10 minutes)**\n1. Open the Zoho API Console at https://api-console.zoho.com, signed in with your Zoho account → **Add Client** → **Server-based Applications**.\n2. Name it (for example \"AnythingMCP\"), homepage URL your company site, and **Authorized Redirect URI** `https://cloud.anythingmcp.com/api/mcp-oauth/callback` on AnythingMCP Cloud, or `/api/mcp-oauth/callback` when you host AnythingMCP yourself. Create it and copy the **Client ID** and **Client Secret** into `ZOHO_BOOKS_CLIENT_ID` and `ZOHO_BOOKS_CLIENT_SECRET`. If your Books organization lives in another data center than the one the console opened in, enable that data center on the client's **Settings** tab (Multi-DC) and use the client secret shown there for it.\n3. Set `ZOHO_BOOKS_ACCOUNTS_URL` and `ZOHO_BOOKS_API_URL` from the table.\n4. Find the **organization ID**: in Zoho Books click the organization name (top right) → **Manage Organizations**; the ID is shown next to each organization. Paste it into `ZOHO_BOOKS_ORGANIZATION_ID`.\n5. Install, then click **Authorize with Provider** on the connector page and approve the requested access. Run `zoho_books_list_organizations` and check that the organization ID is listed.\n\n**Scopes**: `ZohoBooks.settings.ALL,ZohoBooks.contacts.ALL,ZohoBooks.invoices.ALL,ZohoBooks.estimates.ALL,ZohoBooks.customerpayments.ALL,ZohoBooks.bills.ALL,ZohoBooks.expenses.ALL,ZohoBooks.accountants.READ,ZohoBooks.banking.READ`. Items, taxes and organization settings fall under `ZohoBooks.settings`, the chart of accounts under `ZohoBooks.accountants`. For a read-only setup, change every `.ALL` to `.READ` in the connector's OAuth settings, authorize again and switch off the create, update, email, status and delete tools.\n\n**Tokens**: access tokens last one hour and are renewed automatically with the stored refresh token, which does not expire unless revoked. Zoho keeps at most 20 refresh tokens per user and client: authorizing the same client a 21st time (in AnythingMCP or elsewhere) silently revokes the oldest one.\n\n**Writes are real: confirm first.** `zoho_books_email_invoice` emails the customer from your organization, `zoho_books_mark_invoice_sent` and `zoho_books_void_invoice` change the books (void keeps the invoice but zeroes it), and `zoho_books_create_customer_payment`, `zoho_books_create_bill` and `zoho_books_create_expense` post to the ledger. Say what will be booked or sent and wait for the user's yes. The delete tools (contacts, invoices, estimates, customer payments, bills, expenses) are installed switched off.\n\n**Ids and lists**: every record has a numeric string id (`contact_id`, `invoice_id`, ...). List tools return up to 200 records per page and `page_context.has_more_page`; filter with `search_text`, `status`, `filter_by` and dates (`YYYY-MM-DD`). Look up `customer_id` with `zoho_books_list_contacts` (`contact_type: customer`), `item_id` with `zoho_books_list_items`, `account_id` (expense, income, bank and cash accounts) with `zoho_books_list_chart_of_accounts`, and `tax_id` with `zoho_books_list_taxes`.\n\n**Invoices**: `zoho_books_create_invoice` creates a draft numbered by the organization's auto-numbering; `send: true` emails it at once. Then `zoho_books_mark_invoice_sent` (no email) or `zoho_books_email_invoice`. A customer payment is recorded with `zoho_books_create_customer_payment` and applied to invoices via `invoices: [{\"invoice_id\", \"amount_applied\"}]`. To correct a sent invoice, void it with `zoho_books_void_invoice`: it stays on record, which is what bookkeeping rules such as Germany's GoBD expect. Zoho refuses to delete a sent invoice (code 4529, \"cancel the invoice\"); `zoho_books_mark_invoice_draft` turns a void invoice back into an editable draft.\n\n**Reports**: Zoho does not document a reports API for Books. For totals, use `zoho_books_list_chart_of_accounts` with `showbalance: true`, `zoho_books_list_account_transactions` for one account, or the list tools with date filters.\n\n**Limits**: 100 requests per minute per organization, and per day 1,000 (Free), 2,000 (Standard), 5,000 (Professional) or 10,000 (Premium and above); over the limit Zoho answers 429.\n\n**Common errors** (`code` and `message` in the body)\n- 401 code 57 \"You are not authorized to perform this operation\" or `invalid_oauthtoken`: the sign-in is missing a scope or was revoked, or the API host is not your data center; authorize again.\n- 400 or 404 naming the organization: `ZOHO_BOOKS_ORGANIZATION_ID` is wrong or not visible to this user; compare with `zoho_books_list_organizations`.\n- 400 with a field message (a duplicate name, an invalid value, a missing required field): the message names the field.\n- 400 code 4529 \"An invoice that has been sent cannot be deleted\": void it instead (see Invoices). 400 code 3000 on deleting a contact: invoices or payments still refer to it; mark it inactive.\n- 429 code 44 (per minute) or 45 (per day): limit reached.\n\n**Out of scope here**: sales orders, purchase orders, credit notes, vendor payments, journals, projects, recurring invoices, attachments and PDFs.",
+ "region": "intl",
+ "category": "accounting",
+ "icon": "zoho-books",
+ "docsUrl": "https://www.zoho.com/books/api/v3/introduction/",
+ "requiredEnvVars": [
+ "ZOHO_BOOKS_CLIENT_ID",
+ "ZOHO_BOOKS_CLIENT_SECRET",
+ "ZOHO_BOOKS_ORGANIZATION_ID",
+ "ZOHO_BOOKS_ACCOUNTS_URL",
+ "ZOHO_BOOKS_API_URL"
+ ],
+ "appRegistrationUrl": "https://api-console.zoho.com",
+ "envVarMeta": {
+ "ZOHO_BOOKS_CLIENT_ID": {
+ "label": "Client ID",
+ "kind": "credential",
+ "help": "api-console.zoho.com → your Server-based Application → Client Secret tab → Client ID.",
+ "link": "https://api-console.zoho.com",
+ "pattern": "^1000\\.\\S+$",
+ "patternMessage": "Zoho client IDs start with 1000. and contain no spaces."
+ },
+ "ZOHO_BOOKS_CLIENT_SECRET": {
+ "label": "Client secret",
+ "kind": "credential",
+ "secret": true,
+ "help": "Same tab: Client Secret.",
+ "link": "https://api-console.zoho.com",
+ "pattern": "^\\S+$",
+ "patternMessage": "Paste the secret without spaces."
+ },
+ "ZOHO_BOOKS_ORGANIZATION_ID": {
+ "label": "Organization ID",
+ "kind": "setting",
+ "help": "Zoho Books → organization name (top right) → Manage Organizations → the ID next to the organization. zoho_books_list_organizations lists it too.",
+ "example": "20012345678",
+ "pattern": "^\\d{5,20}$",
+ "patternMessage": "The organization ID is a number, digits only."
+ },
+ "ZOHO_BOOKS_ACCOUNTS_URL": {
+ "label": "Zoho sign-in server (data center)",
+ "kind": "address",
+ "help": "https://accounts.zoho.eu for EU accounts, https://accounts.zoho.com for US, .in, .com.au, .jp, .sa, .ae, .com.cn, or https://accounts.zohocloud.ca for Canada. Must be your account's data center.",
+ "example": "https://accounts.zoho.eu",
+ "pattern": "^https://accounts\\.(zoho\\.(com|eu|in|com\\.au|jp|sa|ae|com\\.cn|uk)|zohocloud\\.ca)$",
+ "patternMessage": "Use the accounts server of your data center, e.g. https://accounts.zoho.eu, without a trailing slash."
+ },
+ "ZOHO_BOOKS_API_URL": {
+ "label": "Zoho API host (data center)",
+ "kind": "address",
+ "help": "https://www.zohoapis.eu for EU accounts, https://www.zohoapis.com for US, or .in, .com.au, .jp, .ca, .sa, .ae, .com.cn. Same data center as the sign-in server.",
+ "example": "https://www.zohoapis.eu",
+ "pattern": "^https://www\\.zohoapis\\.(com|eu|in|com\\.au|jp|ca|sa|ae|com\\.cn|uk)$",
+ "patternMessage": "Use the API host of your data center, e.g. https://www.zohoapis.eu, without /books and without a trailing slash."
+ }
+ },
+ "verifyHints": {
+ "401": "Zoho refused the sign-in. Check that ZOHO_BOOKS_ACCOUNTS_URL and ZOHO_BOOKS_API_URL are both your data center (e.g. .eu), then click Authorize with Provider again.",
+ "400": "Zoho Books did not accept the organization. Run zoho_books_list_organizations and copy the right organization_id into ZOHO_BOOKS_ORGANIZATION_ID.",
+ "rate_limited": "Zoho Books allows 100 requests per minute per organization and a daily quota by plan. Wait a minute, or until tomorrow if the daily quota is used up."
+ },
+ "probe": {
+ "tool": "zoho_books_get_organization"
+ },
+ "prerequisites": "A Zoho Books organization (the free plan or a trial works) and a Server-based Application in the Zoho API Console of your data center, with the redirect URI below. About 10 minutes.",
+ "connector": {
+ "name": "Zoho Books API",
+ "type": "REST",
+ "baseUrl": "{{ZOHO_BOOKS_API_URL}}/books/v3",
+ "authType": "OAUTH2",
+ "authConfig": {
+ "clientId": "{{ZOHO_BOOKS_CLIENT_ID}}",
+ "clientSecret": "{{ZOHO_BOOKS_CLIENT_SECRET}}",
+ "authorizationUrl": "{{ZOHO_BOOKS_ACCOUNTS_URL}}/oauth/v2/auth?access_type=offline&prompt=consent",
+ "tokenUrl": "{{ZOHO_BOOKS_ACCOUNTS_URL}}/oauth/v2/token",
+ "scopes": "ZohoBooks.settings.ALL,ZohoBooks.contacts.ALL,ZohoBooks.invoices.ALL,ZohoBooks.estimates.ALL,ZohoBooks.customerpayments.ALL,ZohoBooks.bills.ALL,ZohoBooks.expenses.ALL,ZohoBooks.accountants.READ,ZohoBooks.banking.READ",
+ "tokenPrefix": "Zoho-oauthtoken"
+ },
+ "headers": {
+ "Accept": "application/json",
+ "User-Agent": "AnythingMCP"
+ },
+ "healthcheckPath": "/organizations",
+ "config": {
+ "errorWhen": [
+ {
+ "path": "code",
+ "messagePath": "message",
+ "status": 400
+ }
+ ]
+ }
+ },
+ "tools": [
+ {
+ "name": "zoho_books_list_organizations",
+ "description": "List the Zoho Books organizations this sign-in can reach: organization_id, name, country, currency, time zone and whether it is the default. Use it to check ZOHO_BOOKS_ORGANIZATION_ID.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/organizations"
+ }
+ },
+ {
+ "name": "zoho_books_get_organization",
+ "description": "Read the connected organization: name, address, base currency, fiscal year start, time zone, tax settings and contact details.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/organizations/{ZOHO_BOOKS_ORGANIZATION_ID}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_contacts",
+ "description": "List contacts (customers and vendors) with contact_id, name, company, email, status and outstanding receivables or payables.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_type": {
+ "type": "string",
+ "description": "customer or vendor.",
+ "enum": [
+ "customer",
+ "vendor"
+ ]
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in name, company, email or phone."
+ },
+ "contact_name_contains": {
+ "type": "string",
+ "description": "Part of the contact name."
+ },
+ "email_contains": {
+ "type": "string",
+ "description": "Part of the email address."
+ },
+ "filter_by": {
+ "type": "string",
+ "description": "Status.All, Status.Active, Status.Inactive, Status.Duplicate, Invoice.OverDue, Invoice.Unpaid, Status.CreditLimitExceed."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "contact_name, email, outstanding_receivable_amount, created_time or last_modified_time."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/contacts",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "contact_type": "$contact_type",
+ "search_text": "$search_text",
+ "contact_name_contains": "$contact_name_contains",
+ "email_contains": "$email_contains",
+ "filter_by": "$filter_by",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_contact",
+ "description": "Read one contact with addresses, contact persons, payment terms, currency, balances and custom fields.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_id": {
+ "type": "string",
+ "description": "The contact_id."
+ }
+ },
+ "required": [
+ "contact_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/contacts/{contact_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_contact",
+ "description": "Create a customer or vendor. Returns the contact with its contact_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_name": {
+ "type": "string",
+ "description": "Display name of the contact (required, unique)."
+ },
+ "company_name": {
+ "type": "string",
+ "description": "Company name."
+ },
+ "contact_type": {
+ "type": "string",
+ "description": "customer or vendor.",
+ "enum": [
+ "customer",
+ "vendor"
+ ]
+ },
+ "customer_sub_type": {
+ "type": "string",
+ "description": "business or individual (customers).",
+ "enum": [
+ "business",
+ "individual"
+ ]
+ },
+ "contact_persons": {
+ "type": "array",
+ "description": "People, e.g. [{\"first_name\": \"Ana\", \"last_name\": \"Diaz\", \"email\": \"ana@example.com\", \"phone\": \"+49 761 1234567\", \"is_primary_contact\": true}]. The primary person's email is where invoices are emailed.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "billing_address": {
+ "type": "object",
+ "description": "e.g. {\"attention\": \"Accounts\", \"address\": \"Hauptstr. 1\", \"city\": \"Freiburg\", \"zip\": \"79098\", \"country\": \"Germany\"}."
+ },
+ "shipping_address": {
+ "type": "object",
+ "description": "Same shape as billing_address."
+ },
+ "currency_id": {
+ "type": "string",
+ "description": "Currency id (default: base currency)."
+ },
+ "payment_terms": {
+ "type": "integer",
+ "description": "Payment terms in days, e.g. 30."
+ },
+ "payment_terms_label": {
+ "type": "string",
+ "description": "Label for the terms, e.g. \"Net 30\"."
+ },
+ "vat_reg_no": {
+ "type": "string",
+ "description": "VAT registration number (EU organizations)."
+ },
+ "website": {
+ "type": "string",
+ "description": "Website."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Internal notes."
+ }
+ },
+ "required": [
+ "contact_name"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/contacts",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "contact_name": "$contact_name",
+ "company_name": "$company_name",
+ "contact_type": "$contact_type",
+ "customer_sub_type": "$customer_sub_type",
+ "contact_persons": "$contact_persons",
+ "billing_address": "$billing_address",
+ "shipping_address": "$shipping_address",
+ "currency_id": "$currency_id",
+ "payment_terms": "$payment_terms",
+ "payment_terms_label": "$payment_terms_label",
+ "vat_reg_no": "$vat_reg_no",
+ "website": "$website",
+ "notes": "$notes"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_update_contact",
+ "description": "Update a contact. Zoho requires contact_name and contact_type on every update (send the current values if they do not change); other fields are only changed when given. contact_persons replaces the list.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_id": {
+ "type": "string",
+ "description": "The contact_id."
+ },
+ "contact_name": {
+ "type": "string",
+ "description": "Display name of the contact (required, unique)."
+ },
+ "company_name": {
+ "type": "string",
+ "description": "Company name."
+ },
+ "contact_type": {
+ "type": "string",
+ "description": "customer or vendor.",
+ "enum": [
+ "customer",
+ "vendor"
+ ]
+ },
+ "customer_sub_type": {
+ "type": "string",
+ "description": "business or individual (customers).",
+ "enum": [
+ "business",
+ "individual"
+ ]
+ },
+ "contact_persons": {
+ "type": "array",
+ "description": "People, e.g. [{\"first_name\": \"Ana\", \"last_name\": \"Diaz\", \"email\": \"ana@example.com\", \"phone\": \"+49 761 1234567\", \"is_primary_contact\": true}]. The primary person's email is where invoices are emailed.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "billing_address": {
+ "type": "object",
+ "description": "e.g. {\"attention\": \"Accounts\", \"address\": \"Hauptstr. 1\", \"city\": \"Freiburg\", \"zip\": \"79098\", \"country\": \"Germany\"}."
+ },
+ "shipping_address": {
+ "type": "object",
+ "description": "Same shape as billing_address."
+ },
+ "currency_id": {
+ "type": "string",
+ "description": "Currency id (default: base currency)."
+ },
+ "payment_terms": {
+ "type": "integer",
+ "description": "Payment terms in days, e.g. 30."
+ },
+ "payment_terms_label": {
+ "type": "string",
+ "description": "Label for the terms, e.g. \"Net 30\"."
+ },
+ "vat_reg_no": {
+ "type": "string",
+ "description": "VAT registration number (EU organizations)."
+ },
+ "website": {
+ "type": "string",
+ "description": "Website."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Internal notes."
+ }
+ },
+ "required": [
+ "contact_id",
+ "contact_name",
+ "contact_type"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/contacts/{contact_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "contact_name": "$contact_name",
+ "company_name": "$company_name",
+ "contact_type": "$contact_type",
+ "customer_sub_type": "$customer_sub_type",
+ "contact_persons": "$contact_persons",
+ "billing_address": "$billing_address",
+ "shipping_address": "$shipping_address",
+ "currency_id": "$currency_id",
+ "payment_terms": "$payment_terms",
+ "payment_terms_label": "$payment_terms_label",
+ "vat_reg_no": "$vat_reg_no",
+ "website": "$website",
+ "notes": "$notes"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_mark_contact_inactive",
+ "description": "Mark a contact inactive: it stays in the books but is hidden from lists and cannot be used on new transactions.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_id": {
+ "type": "string",
+ "description": "The contact_id."
+ }
+ },
+ "required": [
+ "contact_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/contacts/{contact_id}/inactive",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_delete_contact",
+ "description": "Delete a contact for good. Not possible while transactions refer to it (mark it inactive instead). Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "contact_id": {
+ "type": "string",
+ "description": "The contact_id."
+ }
+ },
+ "required": [
+ "contact_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/contacts/{contact_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_items",
+ "description": "List items (products and services) with item_id, name, SKU, rate, unit, tax and status.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "search_text": {
+ "type": "string",
+ "description": "Text in name, SKU or description."
+ },
+ "name_contains": {
+ "type": "string",
+ "description": "Part of the item name."
+ },
+ "filter_by": {
+ "type": "string",
+ "description": "Status.All, Status.Active or Status.Inactive."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "name, rate or tax_name."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/items",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "search_text": "$search_text",
+ "name_contains": "$name_contains",
+ "filter_by": "$filter_by",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_item",
+ "description": "Read one item with selling and purchase prices, accounts, tax, unit and stock details.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "The item_id."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/items/{item_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_item",
+ "description": "Create an item (product or service). Returns the item with its item_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Item name (unique)."
+ },
+ "rate": {
+ "type": "number",
+ "description": "Selling price per unit."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description printed on documents."
+ },
+ "sku": {
+ "type": "string",
+ "description": "SKU."
+ },
+ "unit": {
+ "type": "string",
+ "description": "Unit, e.g. \"pcs\" or \"hrs\"."
+ },
+ "product_type": {
+ "type": "string",
+ "description": "goods, service or digital_service.",
+ "enum": [
+ "goods",
+ "service",
+ "digital_service"
+ ]
+ },
+ "item_type": {
+ "type": "string",
+ "description": "sales (default), purchases, sales_and_purchases or inventory.",
+ "enum": [
+ "sales",
+ "purchases",
+ "sales_and_purchases",
+ "inventory"
+ ]
+ },
+ "tax_id": {
+ "type": "string",
+ "description": "Default tax id."
+ },
+ "account_id": {
+ "type": "string",
+ "description": "Income account id."
+ },
+ "purchase_rate": {
+ "type": "number",
+ "description": "Purchase price per unit."
+ },
+ "purchase_account_id": {
+ "type": "string",
+ "description": "Expense account id for purchases."
+ }
+ },
+ "required": [
+ "name",
+ "rate"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/items",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "name": "$name",
+ "rate": "$rate",
+ "description": "$description",
+ "sku": "$sku",
+ "unit": "$unit",
+ "product_type": "$product_type",
+ "item_type": "$item_type",
+ "tax_id": "$tax_id",
+ "account_id": "$account_id",
+ "purchase_rate": "$purchase_rate",
+ "purchase_account_id": "$purchase_account_id"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_update_item",
+ "description": "Update an item. Zoho requires name and rate on every update (send the current values if they do not change).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "The item_id."
+ },
+ "name": {
+ "type": "string",
+ "description": "Item name (unique)."
+ },
+ "rate": {
+ "type": "number",
+ "description": "Selling price per unit."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description printed on documents."
+ },
+ "sku": {
+ "type": "string",
+ "description": "SKU."
+ },
+ "unit": {
+ "type": "string",
+ "description": "Unit, e.g. \"pcs\" or \"hrs\"."
+ },
+ "product_type": {
+ "type": "string",
+ "description": "goods, service or digital_service.",
+ "enum": [
+ "goods",
+ "service",
+ "digital_service"
+ ]
+ },
+ "item_type": {
+ "type": "string",
+ "description": "sales (default), purchases, sales_and_purchases or inventory.",
+ "enum": [
+ "sales",
+ "purchases",
+ "sales_and_purchases",
+ "inventory"
+ ]
+ },
+ "tax_id": {
+ "type": "string",
+ "description": "Default tax id."
+ },
+ "account_id": {
+ "type": "string",
+ "description": "Income account id."
+ },
+ "purchase_rate": {
+ "type": "number",
+ "description": "Purchase price per unit."
+ },
+ "purchase_account_id": {
+ "type": "string",
+ "description": "Expense account id for purchases."
+ }
+ },
+ "required": [
+ "item_id",
+ "name",
+ "rate"
+ ]
+ },
+ "endpointMapping": {
+ "method": "PUT",
+ "path": "/items/{item_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "name": "$name",
+ "rate": "$rate",
+ "description": "$description",
+ "sku": "$sku",
+ "unit": "$unit",
+ "product_type": "$product_type",
+ "item_type": "$item_type",
+ "tax_id": "$tax_id",
+ "account_id": "$account_id",
+ "purchase_rate": "$purchase_rate",
+ "purchase_account_id": "$purchase_account_id"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_mark_item_inactive",
+ "description": "Mark an item inactive so it can no longer be added to new documents.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "item_id": {
+ "type": "string",
+ "description": "The item_id."
+ }
+ },
+ "required": [
+ "item_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/items/{item_id}/inactive",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_invoices",
+ "description": "List invoices with invoice_id, number, customer, status, date, due date, total and balance.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "description": "sent, draft, overdue, paid, void, unpaid, partially_paid or viewed.",
+ "enum": [
+ "sent",
+ "draft",
+ "overdue",
+ "paid",
+ "void",
+ "unpaid",
+ "partially_paid",
+ "viewed"
+ ]
+ },
+ "customer_id": {
+ "type": "string",
+ "description": "Only this customer."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in number, reference or customer name."
+ },
+ "invoice_number": {
+ "type": "string",
+ "description": "Exact invoice number."
+ },
+ "reference_number": {
+ "type": "string",
+ "description": "Reference number."
+ },
+ "date_start": {
+ "type": "string",
+ "description": "Invoice date from, YYYY-MM-DD."
+ },
+ "date_end": {
+ "type": "string",
+ "description": "Invoice date until, YYYY-MM-DD."
+ },
+ "due_date_start": {
+ "type": "string",
+ "description": "Due date from, YYYY-MM-DD."
+ },
+ "due_date_end": {
+ "type": "string",
+ "description": "Due date until, YYYY-MM-DD."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "customer_name, invoice_number, date, due_date, total, balance or created_time."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/invoices",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "status": "$status",
+ "customer_id": "$customer_id",
+ "search_text": "$search_text",
+ "invoice_number": "$invoice_number",
+ "reference_number": "$reference_number",
+ "date_start": "$date_start",
+ "date_end": "$date_end",
+ "due_date_start": "$due_date_start",
+ "due_date_end": "$due_date_end",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_invoice",
+ "description": "Read one invoice with line items, taxes, totals, balance, payments, customer and status.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice_id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/invoices/{invoice_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_invoice",
+ "description": "Create an invoice (a draft, unless send is true, which also emails it to the customer: confirm that with the user first). Returns the invoice with invoice_id and invoice_number.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer contact_id."
+ },
+ "line_items": {
+ "type": "array",
+ "description": "Line items, e.g. [{\"item_id\": \"460000000017088\", \"quantity\": 2, \"rate\": 120}] or with a free description [{\"name\": \"Consulting\", \"description\": \"October\", \"quantity\": 1, \"rate\": 500, \"tax_id\": \"...\"}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "date": {
+ "type": "string",
+ "description": "Invoice date, YYYY-MM-DD (default today)."
+ },
+ "due_date": {
+ "type": "string",
+ "description": "Due date, YYYY-MM-DD."
+ },
+ "payment_terms": {
+ "type": "integer",
+ "description": "Payment terms in days."
+ },
+ "reference_number": {
+ "type": "string",
+ "description": "Reference, e.g. an order number."
+ },
+ "invoice_number": {
+ "type": "string",
+ "description": "Own number; only together with ignore_auto_number_generation true."
+ },
+ "ignore_auto_number_generation": {
+ "type": "boolean",
+ "description": "true to use invoice_number instead of the next automatic number."
+ },
+ "currency_id": {
+ "type": "string",
+ "description": "Currency id (default: the customer's)."
+ },
+ "discount": {
+ "type": "number",
+ "description": "Discount, an amount or percentage depending on discount_type."
+ },
+ "is_inclusive_tax": {
+ "type": "boolean",
+ "description": "true when rates include tax."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Notes printed on the invoice."
+ },
+ "terms": {
+ "type": "string",
+ "description": "Terms and conditions printed on the invoice."
+ },
+ "custom_fields": {
+ "type": "array",
+ "description": "Custom field values, e.g. [{\"customfield_id\": \"...\", \"value\": \"...\"}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "send": {
+ "type": "boolean",
+ "description": "true to email the invoice to the customer right away (default false).",
+ "default": false
+ }
+ },
+ "required": [
+ "customer_id",
+ "line_items"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoices",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "send": "$send",
+ "ignore_auto_number_generation": "$ignore_auto_number_generation"
+ },
+ "bodyMapping": {
+ "customer_id": "$customer_id",
+ "line_items": "$line_items",
+ "date": "$date",
+ "due_date": "$due_date",
+ "payment_terms": "$payment_terms",
+ "reference_number": "$reference_number",
+ "invoice_number": "$invoice_number",
+ "currency_id": "$currency_id",
+ "discount": "$discount",
+ "is_inclusive_tax": "$is_inclusive_tax",
+ "notes": "$notes",
+ "terms": "$terms",
+ "custom_fields": "$custom_fields"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_email_invoice",
+ "description": "Email an invoice to the customer from the organization, with the invoice attached. This reaches the customer: confirm recipients and text with the user first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice_id."
+ },
+ "to_mail_ids": {
+ "type": "array",
+ "description": "Recipient email addresses.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "cc_mail_ids": {
+ "type": "array",
+ "description": "CC email addresses.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "subject": {
+ "type": "string",
+ "description": "Subject; the template's when omitted."
+ },
+ "body": {
+ "type": "string",
+ "description": "Message text (HTML allowed); the template's when omitted."
+ }
+ },
+ "required": [
+ "invoice_id",
+ "to_mail_ids"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoices/{invoice_id}/email",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "to_mail_ids": "$to_mail_ids",
+ "cc_mail_ids": "$cc_mail_ids",
+ "subject": "$subject",
+ "body": "$body"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_mark_invoice_sent",
+ "description": "Mark a draft invoice as sent without emailing it (for invoices delivered another way). It then counts as receivable.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice_id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoices/{invoice_id}/status/sent",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_void_invoice",
+ "description": "Void an invoice: it stays on record with status void but no longer counts as receivable or revenue. Confirm with the user first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice_id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoices/{invoice_id}/status/void",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ },
+ "annotations": {
+ "readOnlyHint": false,
+ "destructiveHint": true,
+ "idempotentHint": false
+ }
+ },
+ {
+ "name": "zoho_books_mark_invoice_draft",
+ "description": "Turn a void invoice back into a draft, for example to correct and reissue it. Zoho only allows this for void invoices. Confirm with the user first.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice_id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/invoices/{invoice_id}/status/draft",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_delete_invoice",
+ "description": "Delete an invoice for good: only a draft without recorded payments. A sent invoice is refused (code 4529); void it instead. Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "invoice_id": {
+ "type": "string",
+ "description": "The invoice_id."
+ }
+ },
+ "required": [
+ "invoice_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/invoices/{invoice_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_estimates",
+ "description": "List estimates (quotes) with estimate_id, number, customer, status, date, expiry date and total.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "description": "draft, sent, invoiced, accepted, declined or expired.",
+ "enum": [
+ "draft",
+ "sent",
+ "invoiced",
+ "accepted",
+ "declined",
+ "expired"
+ ]
+ },
+ "customer_id": {
+ "type": "string",
+ "description": "Only this customer."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in number, reference or customer name."
+ },
+ "date_start": {
+ "type": "string",
+ "description": "Date from, YYYY-MM-DD."
+ },
+ "date_end": {
+ "type": "string",
+ "description": "Date until, YYYY-MM-DD."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "customer_name, estimate_number, date, total or created_time."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/estimates",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "status": "$status",
+ "customer_id": "$customer_id",
+ "search_text": "$search_text",
+ "date_start": "$date_start",
+ "date_end": "$date_end",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_estimate",
+ "description": "Read one estimate with line items, taxes, totals and status.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "estimate_id": {
+ "type": "string",
+ "description": "The estimate_id."
+ }
+ },
+ "required": [
+ "estimate_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/estimates/{estimate_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_estimate",
+ "description": "Create an estimate (quote) as a draft. Returns the estimate with estimate_id and number.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer contact_id."
+ },
+ "line_items": {
+ "type": "array",
+ "description": "Line items, e.g. [{\"item_id\": \"460000000017088\", \"quantity\": 2, \"rate\": 120}] or with a free description [{\"name\": \"Consulting\", \"description\": \"October\", \"quantity\": 1, \"rate\": 500, \"tax_id\": \"...\"}].",
+ "items": {
+ "type": "object"
+ }
+ },
+ "date": {
+ "type": "string",
+ "description": "Estimate date, YYYY-MM-DD."
+ },
+ "expiry_date": {
+ "type": "string",
+ "description": "Valid until, YYYY-MM-DD."
+ },
+ "reference_number": {
+ "type": "string",
+ "description": "Reference."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Notes printed on the estimate."
+ },
+ "terms": {
+ "type": "string",
+ "description": "Terms printed on the estimate."
+ }
+ },
+ "required": [
+ "customer_id",
+ "line_items"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/estimates",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "customer_id": "$customer_id",
+ "line_items": "$line_items",
+ "date": "$date",
+ "expiry_date": "$expiry_date",
+ "reference_number": "$reference_number",
+ "notes": "$notes",
+ "terms": "$terms"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_delete_estimate",
+ "description": "Delete an estimate (quote) for good; it cannot be restored. Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "estimate_id": {
+ "type": "string",
+ "description": "The estimate_id."
+ }
+ },
+ "required": [
+ "estimate_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/estimates/{estimate_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_customer_payments",
+ "description": "List payments received from customers with payment_id, customer, date, amount, payment mode and the invoices they paid.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "Only this customer."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in reference, customer name or payment number."
+ },
+ "date": {
+ "type": "string",
+ "description": "Payment date, YYYY-MM-DD."
+ },
+ "filter_by": {
+ "type": "string",
+ "description": "PaymentMode.All, PaymentMode.Check, PaymentMode.Cash, PaymentMode.BankTransfer, PaymentMode.CreditCard, ..."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "customer_name, payment_number, date, amount or created_time."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/customerpayments",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "customer_id": "$customer_id",
+ "search_text": "$search_text",
+ "date": "$date",
+ "filter_by": "$filter_by",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_customer_payment",
+ "description": "Read one customer payment with the invoices it was applied to.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "payment_id": {
+ "type": "string",
+ "description": "The payment_id."
+ }
+ },
+ "required": [
+ "payment_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/customerpayments/{payment_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_customer_payment",
+ "description": "Record a payment received from a customer and apply it to invoices (bookkeeping only; no money is collected). Confirm amount and invoices with the user first. Returns the payment with payment_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "customer_id": {
+ "type": "string",
+ "description": "The customer contact_id."
+ },
+ "amount": {
+ "type": "number",
+ "description": "Amount received."
+ },
+ "date": {
+ "type": "string",
+ "description": "Payment date, YYYY-MM-DD."
+ },
+ "payment_mode": {
+ "type": "string",
+ "description": "check, cash, creditcard, banktransfer, bankremittance, autotransaction or others."
+ },
+ "invoices": {
+ "type": "array",
+ "description": "Invoices paid, e.g. [{\"invoice_id\": \"...\", \"amount_applied\": 119.0}]. Amounts not applied stay as customer credit.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "account_id": {
+ "type": "string",
+ "description": "Deposit-to account id (bank or cash account)."
+ },
+ "reference_number": {
+ "type": "string",
+ "description": "Reference, e.g. the bank transfer id."
+ },
+ "description": {
+ "type": "string",
+ "description": "Note."
+ },
+ "bank_charges": {
+ "type": "number",
+ "description": "Bank charges deducted."
+ }
+ },
+ "required": [
+ "customer_id",
+ "amount",
+ "date",
+ "payment_mode",
+ "invoices"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/customerpayments",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "customer_id": "$customer_id",
+ "amount": "$amount",
+ "date": "$date",
+ "payment_mode": "$payment_mode",
+ "invoices": "$invoices",
+ "account_id": "$account_id",
+ "reference_number": "$reference_number",
+ "description": "$description",
+ "bank_charges": "$bank_charges"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_delete_customer_payment",
+ "description": "Delete a recorded customer payment; the invoices become unpaid again. Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "payment_id": {
+ "type": "string",
+ "description": "The payment_id."
+ }
+ },
+ "required": [
+ "payment_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/customerpayments/{payment_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_bills",
+ "description": "List bills (vendor invoices) with bill_id, number, vendor, status, date, due date, total and balance.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "description": "paid, open, overdue, void, partially_paid, unpaid or draft."
+ },
+ "vendor_id": {
+ "type": "string",
+ "description": "Only this vendor."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in number, reference or vendor name."
+ },
+ "date_start": {
+ "type": "string",
+ "description": "Bill date from, YYYY-MM-DD."
+ },
+ "date_end": {
+ "type": "string",
+ "description": "Bill date until, YYYY-MM-DD."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "vendor_name, bill_number, date, due_date, total or balance."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/bills",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "status": "$status",
+ "vendor_id": "$vendor_id",
+ "search_text": "$search_text",
+ "date_start": "$date_start",
+ "date_end": "$date_end",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_bill",
+ "description": "Read one bill with line items, accounts, taxes, totals, balance and payments.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "bill_id": {
+ "type": "string",
+ "description": "The bill_id."
+ }
+ },
+ "required": [
+ "bill_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/bills/{bill_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_bill",
+ "description": "Record a bill received from a vendor (it posts to the ledger as payable). Confirm with the user first. Returns the bill with bill_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "vendor_id": {
+ "type": "string",
+ "description": "The vendor contact_id."
+ },
+ "bill_number": {
+ "type": "string",
+ "description": "The vendor's invoice number."
+ },
+ "date": {
+ "type": "string",
+ "description": "Bill date, YYYY-MM-DD."
+ },
+ "due_date": {
+ "type": "string",
+ "description": "Due date, YYYY-MM-DD."
+ },
+ "line_items": {
+ "type": "array",
+ "description": "Lines, e.g. [{\"account_id\": \"\", \"description\": \"Office chairs\", \"quantity\": 2, \"rate\": 150, \"tax_id\": \"...\"}] or with item_id.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "reference_number": {
+ "type": "string",
+ "description": "Reference, e.g. your purchase order."
+ },
+ "is_inclusive_tax": {
+ "type": "boolean",
+ "description": "true when rates include tax."
+ },
+ "notes": {
+ "type": "string",
+ "description": "Notes."
+ }
+ },
+ "required": [
+ "vendor_id",
+ "bill_number",
+ "line_items"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/bills",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "vendor_id": "$vendor_id",
+ "bill_number": "$bill_number",
+ "date": "$date",
+ "due_date": "$due_date",
+ "line_items": "$line_items",
+ "reference_number": "$reference_number",
+ "is_inclusive_tax": "$is_inclusive_tax",
+ "notes": "$notes"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_delete_bill",
+ "description": "Delete a bill for good (only without recorded payments). Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "bill_id": {
+ "type": "string",
+ "description": "The bill_id."
+ }
+ },
+ "required": [
+ "bill_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/bills/{bill_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_expenses",
+ "description": "List expenses with expense_id, date, account, amount, vendor, customer and status (billable, invoiced, ...).",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "description": "unbilled, invoiced, reimbursed, non-billable or billable."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in description, reference or account name."
+ },
+ "vendor_id": {
+ "type": "string",
+ "description": "Only this vendor."
+ },
+ "customer_id": {
+ "type": "string",
+ "description": "Only this customer."
+ },
+ "date_start": {
+ "type": "string",
+ "description": "Date from, YYYY-MM-DD."
+ },
+ "date_end": {
+ "type": "string",
+ "description": "Date until, YYYY-MM-DD."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "date, account_name, total, reference_number, customer_name or created_time."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/expenses",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "status": "$status",
+ "search_text": "$search_text",
+ "vendor_id": "$vendor_id",
+ "customer_id": "$customer_id",
+ "date_start": "$date_start",
+ "date_end": "$date_end",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_expense",
+ "description": "Read one expense with account, paid-through account, tax, vendor, customer and receipts.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "expense_id": {
+ "type": "string",
+ "description": "The expense_id."
+ }
+ },
+ "required": [
+ "expense_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/expenses/{expense_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_create_expense",
+ "description": "Record an expense paid from a bank, cash or credit card account (it posts to the ledger). Confirm with the user first. Returns the expense with expense_id.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "Expense account id (chart of accounts, type expense)."
+ },
+ "paid_through_account_id": {
+ "type": "string",
+ "description": "Account it was paid from (bank, cash or credit card account id)."
+ },
+ "date": {
+ "type": "string",
+ "description": "Date, YYYY-MM-DD."
+ },
+ "amount": {
+ "type": "number",
+ "description": "Amount."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description."
+ },
+ "reference_number": {
+ "type": "string",
+ "description": "Reference, e.g. receipt number."
+ },
+ "vendor_id": {
+ "type": "string",
+ "description": "Vendor contact_id."
+ },
+ "customer_id": {
+ "type": "string",
+ "description": "Customer to bill it to."
+ },
+ "is_billable": {
+ "type": "boolean",
+ "description": "true if it should be billed to the customer."
+ },
+ "tax_id": {
+ "type": "string",
+ "description": "Tax id."
+ },
+ "is_inclusive_tax": {
+ "type": "boolean",
+ "description": "true when the amount includes tax."
+ }
+ },
+ "required": [
+ "account_id",
+ "paid_through_account_id",
+ "date",
+ "amount"
+ ]
+ },
+ "endpointMapping": {
+ "method": "POST",
+ "path": "/expenses",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ },
+ "bodyMapping": {
+ "account_id": "$account_id",
+ "paid_through_account_id": "$paid_through_account_id",
+ "date": "$date",
+ "amount": "$amount",
+ "description": "$description",
+ "reference_number": "$reference_number",
+ "vendor_id": "$vendor_id",
+ "customer_id": "$customer_id",
+ "is_billable": "$is_billable",
+ "tax_id": "$tax_id",
+ "is_inclusive_tax": "$is_inclusive_tax"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_delete_expense",
+ "description": "Delete an expense for good; its ledger postings are removed. Confirm with the user first.",
+ "enabled": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "expense_id": {
+ "type": "string",
+ "description": "The expense_id."
+ }
+ },
+ "required": [
+ "expense_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "DELETE",
+ "path": "/expenses/{expense_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_chart_of_accounts",
+ "description": "List the chart of accounts: account_id, name, code, type (asset, liability, equity, income, expense, bank, cash, ...), active flag and, with showbalance, the current balance.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "filter_by": {
+ "type": "string",
+ "description": "AccountType.All, AccountType.Active, AccountType.Inactive, AccountType.Asset, AccountType.Liability, AccountType.Equity, AccountType.Income or AccountType.Expense."
+ },
+ "showbalance": {
+ "type": "boolean",
+ "description": "true to include each account's current balance."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in account name or code."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "account_name, account_type or account_code."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/chartofaccounts",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "filter_by": "$filter_by",
+ "showbalance": "$showbalance",
+ "search_text": "$search_text",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_account",
+ "description": "Read one account of the chart of accounts: name, code, type, currency, description and balance.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "The account_id."
+ }
+ },
+ "required": [
+ "account_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/chartofaccounts/{account_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_account_transactions",
+ "description": "List the transactions booked on one account (invoices, payments, bills, expenses, journals) with date, debit, credit and reference: a ledger view of the account.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "The account_id (required)."
+ },
+ "date_start": {
+ "type": "string",
+ "description": "Date from, YYYY-MM-DD."
+ },
+ "date_end": {
+ "type": "string",
+ "description": "Date until, YYYY-MM-DD."
+ },
+ "transaction_type": {
+ "type": "string",
+ "description": "Only one type, e.g. invoice, customer_payment, bills, expense, journal."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "transaction_date, debit_amount or credit_amount."
+ },
+ "sort_order": {
+ "type": "string",
+ "description": "A (ascending) or D (descending).",
+ "enum": [
+ "A",
+ "D"
+ ]
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ },
+ "required": [
+ "account_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/chartofaccounts/accounttransactions",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "account_id": "$account_id",
+ "date_start": "$date_start",
+ "date_end": "$date_end",
+ "transaction_type": "$transaction_type",
+ "sort_column": "$sort_column",
+ "sort_order": "$sort_order",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_bank_accounts",
+ "description": "List bank, cash and credit card accounts with account_id, name, type, currency, balance and bank name.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "filter_by": {
+ "type": "string",
+ "description": "Status.All, Status.Active or Status.Inactive."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "account_name, account_type or account_code."
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/bankaccounts",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "filter_by": "$filter_by",
+ "sort_column": "$sort_column"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_bank_account",
+ "description": "Read one bank or credit card account with balance and bank details.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "The bank account_id."
+ }
+ },
+ "required": [
+ "account_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/bankaccounts/{account_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_bank_transactions",
+ "description": "List bank transactions (deposits, withdrawals, transfers and feed lines) with date, amount, type, status (categorized, uncategorized, matched, excluded) and reference.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "Only this bank account."
+ },
+ "filter_by": {
+ "type": "string",
+ "description": "Status.All, Status.Uncategorized, Status.Categorized, Status.ManuallyAdded, Status.Excluded or Status.Matched."
+ },
+ "transaction_type": {
+ "type": "string",
+ "description": "e.g. deposit, expense, transfer_fund, card_payment."
+ },
+ "date_start": {
+ "type": "string",
+ "description": "Date from, YYYY-MM-DD."
+ },
+ "date_end": {
+ "type": "string",
+ "description": "Date until, YYYY-MM-DD."
+ },
+ "search_text": {
+ "type": "string",
+ "description": "Text in reference, payee or description."
+ },
+ "sort_column": {
+ "type": "string",
+ "description": "date."
+ },
+ "page": {
+ "type": "integer",
+ "description": "Page number, starting at 1.",
+ "minimum": 1
+ },
+ "per_page": {
+ "type": "integer",
+ "description": "Records per page, up to 200 (default 200).",
+ "minimum": 1,
+ "maximum": 200
+ }
+ }
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/banktransactions",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}",
+ "account_id": "$account_id",
+ "filter_by": "$filter_by",
+ "transaction_type": "$transaction_type",
+ "date_start": "$date_start",
+ "date_end": "$date_end",
+ "search_text": "$search_text",
+ "sort_column": "$sort_column",
+ "page": "$page",
+ "per_page": "$per_page"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_get_bank_transaction",
+ "description": "Read one bank transaction with its accounts, amounts and the documents it is matched to.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "bank_transaction_id": {
+ "type": "string",
+ "description": "The bank_transaction_id."
+ }
+ },
+ "required": [
+ "bank_transaction_id"
+ ]
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/banktransactions/{bank_transaction_id}",
+ "encodePathParams": true,
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ },
+ {
+ "name": "zoho_books_list_taxes",
+ "description": "List the organization's taxes and tax groups with tax_id, name, percentage and type, for line items and expenses.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ },
+ "endpointMapping": {
+ "method": "GET",
+ "path": "/settings/taxes",
+ "queryParams": {
+ "organization_id": "{{ZOHO_BOOKS_ORGANIZATION_ID}}"
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/backend/src/adapters/intl/zoho-books.live.spec.ts b/packages/backend/src/adapters/intl/zoho-books.live.spec.ts
new file mode 100644
index 000000000..2fab715f6
--- /dev/null
+++ b/packages/backend/src/adapters/intl/zoho-books.live.spec.ts
@@ -0,0 +1,481 @@
+import axios from 'axios';
+import * as adapter from './zoho-books.json';
+import { RestEngine } from '../../connectors/engines/rest.engine';
+import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service';
+import { LoginTokenService } from '../../connectors/engines/login-token.service';
+import { ResponseBodyError } from '../../connectors/engines/response-error.util';
+import { deriveToolAnnotations } from '../../mcp-server/tool-annotations';
+import { applySchemaDefaults } from '../../common/schema-defaults.util';
+import { interpolateConnectorConfig } from '../../common/env-interpolation.util';
+import { describeAdapterEnvVars } from '../env-var-meta';
+import { getAdapter, listAdapters } from '../catalog';
+
+/**
+ * Two layers of verification for the Zoho Books adapter:
+ *
+ * 1. Static: always runs. Pins the Zoho OAuth endpoints on the configurable
+ * data-center sign-in server (offline access, consent prompt, the
+ * `Zoho-oauthtoken` header), the API host, organization_id on every call
+ * but the organization list, every tool's method and URL, the create
+ * bodies, the errorWhen rule for a non-zero `code`, and which tools are
+ * read-only, destructive or switched off.
+ *
+ * 2. Live: skipped unless ZOHO_BOOKS_ACCESS_TOKEN and
+ * ZOHO_BOOKS_ORGANIZATION_ID are set. ZOHO_BOOKS_API_URL defaults to the
+ * EU host (https://www.zohoapis.eu):
+ *
+ * ZOHO_BOOKS_ACCESS_TOKEN=1000.abc... ZOHO_BOOKS_ORGANIZATION_ID=20012345678 \
+ * npx jest src/adapters/intl/zoho-books.live.spec.ts
+ *
+ * Reads organizations, contacts, items, invoices, estimates, customer
+ * payments, bills, expenses, the chart of accounts with one account's
+ * transactions, bank accounts and transactions, and taxes.
+ *
+ * ZOHO_BOOKS_LIVE_WRITE=1 (use a test organization) adds round-trips on
+ * "AnythingMCP test" records, each cleaned up in `finally`: a customer
+ * (created, read, updated, deleted), an item (created, updated, made
+ * inactive), an invoice (created, marked sent, paid with a recorded
+ * customer payment that is deleted again, voided, deleted), an estimate,
+ * and a vendor with a bill and an expense. With ZOHO_BOOKS_TEST_EMAIL the
+ * invoice is also emailed to that address.
+ */
+
+jest.mock('axios', () => {
+ const actual = jest.requireActual('axios');
+ const mocked = jest.fn();
+ return {
+ __esModule: true,
+ default: Object.assign(mocked, actual.default, { __actual: actual.default }),
+ AxiosError: actual.AxiosError,
+ };
+});
+const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios };
+
+type Tool = {
+ name: string;
+ description: string;
+ enabled?: boolean;
+ parameters: { properties?: Record; required?: string[] };
+ endpointMapping: {
+ method: string;
+ path: string;
+ headers?: Record;
+ queryParams?: Record;
+ bodyMapping?: Record;
+ };
+ annotations?: Record;
+};
+const a = adapter as unknown as {
+ slug: string;
+ unlisted?: boolean;
+ instructions: string;
+ prerequisites: string;
+ requiredEnvVars: string[];
+ envVarMeta: Record;
+ probe: { tool: string };
+ connector: {
+ baseUrl: string;
+ authType: string;
+ authConfig: Record;
+ headers: Record;
+ healthcheckPath: string;
+ config: { errorWhen: unknown };
+ };
+ tools: Tool[];
+};
+const tool = (name: string) => {
+ const t = a.tools.find((x) => x.name === name);
+ if (!t) throw new Error(`missing tool ${name}`);
+ return t;
+};
+const annotationsOf = (t: Tool) =>
+ deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations });
+
+const ORG = '20012345678';
+const EU = 'https://www.zohoapis.eu';
+const BASE = `${EU}/books/v3`;
+// Mirrors DynamicMcpTools: {{VAR}} interpolated, env vars also passed as params,
+// and the OAuth2 token sent with the adapter's prefix.
+const execute = (name: string, env: Record, token: string, params: Record) => {
+ const { config, endpointMapping } = interpolateConnectorConfig(
+ { baseUrl: a.connector.baseUrl, headers: a.connector.headers },
+ tool(name).endpointMapping,
+ env,
+ );
+ return new RestEngine(
+ { getAccessToken: jest.fn().mockResolvedValue(token) } as unknown as OAuth2TokenService,
+ {} as LoginTokenService,
+ ).execute(
+ {
+ baseUrl: config.baseUrl,
+ authType: 'OAUTH2',
+ authConfig: { tokenPrefix: a.connector.authConfig.tokenPrefix },
+ headers: config.headers,
+ errorWhen: a.connector.config.errorWhen,
+ },
+ endpointMapping,
+ { ...applySchemaDefaults(tool(name).parameters, params), ...env },
+ ) as Promise;
+};
+const ENV = { ZOHO_BOOKS_API_URL: EU, ZOHO_BOOKS_ORGANIZATION_ID: ORG };
+const call = (name: string, params: Record) => execute(name, ENV, 'test-token', params);
+const sent = () => mockedAxios.mock.calls[0][0];
+
+describe('zoho-books adapter: static spec conformance', () => {
+ beforeEach(() => mockedAxios.mockReset());
+
+ it('is listed (verified live on 11 Oct 2026)', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.prerequisites).toMatch(/free plan|trial/);
+ });
+
+ it('signs in at the chosen data center with offline access and sends Zoho-oauthtoken', async () => {
+ expect(a.connector.authType).toBe('OAUTH2');
+ expect(a.connector.authConfig).toEqual({
+ clientId: '{{ZOHO_BOOKS_CLIENT_ID}}',
+ clientSecret: '{{ZOHO_BOOKS_CLIENT_SECRET}}',
+ authorizationUrl: '{{ZOHO_BOOKS_ACCOUNTS_URL}}/oauth/v2/auth?access_type=offline&prompt=consent',
+ tokenUrl: '{{ZOHO_BOOKS_ACCOUNTS_URL}}/oauth/v2/token',
+ scopes: expect.stringContaining('ZohoBooks.invoices.ALL'),
+ tokenPrefix: 'Zoho-oauthtoken',
+ });
+ // Zoho documents client_secret_post; the engine's default for the code grant.
+ expect(a.connector.authConfig.tokenAuthMethod).toBeUndefined();
+ for (const scope of ['ZohoBooks.settings.ALL', 'ZohoBooks.contacts.ALL', 'ZohoBooks.accountants.READ', 'ZohoBooks.banking.READ']) {
+ expect(a.connector.authConfig.scopes.split(',')).toContain(scope);
+ }
+ expect(a.instructions).toContain(a.connector.authConfig.scopes);
+ expect(a.instructions).toContain('https://cloud.anythingmcp.com/api/mcp-oauth/callback');
+ mockedAxios.mockResolvedValue({ data: { code: 0 } });
+ await call('zoho_books_list_organizations', {});
+ expect(sent().headers.Authorization).toBe('Zoho-oauthtoken test-token');
+ });
+
+ it('validates the data-center hosts and the organization id', () => {
+ expect(a.requiredEnvVars).toEqual(['ZOHO_BOOKS_CLIENT_ID', 'ZOHO_BOOKS_CLIENT_SECRET', 'ZOHO_BOOKS_ORGANIZATION_ID', 'ZOHO_BOOKS_ACCOUNTS_URL', 'ZOHO_BOOKS_API_URL']);
+ expect(a.connector.baseUrl).toBe('{{ZOHO_BOOKS_API_URL}}/books/v3');
+ const accounts = new RegExp(a.envVarMeta.ZOHO_BOOKS_ACCOUNTS_URL.pattern!);
+ for (const ok of ['https://accounts.zoho.eu', 'https://accounts.zoho.com', 'https://accounts.zoho.in', 'https://accounts.zoho.com.au', 'https://accounts.zohocloud.ca']) {
+ expect(accounts.test(ok)).toBe(true);
+ }
+ expect(accounts.test('https://accounts.zoho.eu/')).toBe(false);
+ expect(accounts.test('https://accounts.zoho.eu.evil.example')).toBe(false);
+ const api = new RegExp(a.envVarMeta.ZOHO_BOOKS_API_URL.pattern!);
+ for (const ok of [EU, 'https://www.zohoapis.com', 'https://www.zohoapis.in', 'https://www.zohoapis.ca', 'https://www.zohoapis.com.au']) {
+ expect(api.test(ok)).toBe(true);
+ }
+ expect(api.test(`${EU}/books/v3`)).toBe(false);
+ expect(new RegExp(a.envVarMeta.ZOHO_BOOKS_ORGANIZATION_ID.pattern!).test(ORG)).toBe(true);
+ expect(new RegExp(a.envVarMeta.ZOHO_BOOKS_CLIENT_ID.pattern!).test('1000.ABCDEF123')).toBe(true);
+ const described = describeAdapterEnvVars(a as never);
+ expect(described.find((d) => d.name === 'ZOHO_BOOKS_CLIENT_SECRET')!.secret).toBe(true);
+ expect(described.find((d) => d.name === 'ZOHO_BOOKS_API_URL')!.kind).toBe('address');
+ });
+
+ it('probes with the organization and health-checks the organization list', () => {
+ expect(a.probe.tool).toBe('zoho_books_get_organization');
+ expect(tool('zoho_books_get_organization').parameters.required).toBeUndefined();
+ expect(a.connector.healthcheckPath).toBe('/organizations');
+ });
+
+ it('sends organization_id on every call except the organization list', () => {
+ for (const t of a.tools) {
+ const org = t.endpointMapping.queryParams?.organization_id;
+ expect(`${t.name}:${org}`).toBe(`${t.name}:${t.name === 'zoho_books_list_organizations' ? undefined : '{{ZOHO_BOOKS_ORGANIZATION_ID}}'}`);
+ }
+ });
+
+ it('prefixes every tool with zoho_books_ and shares no tool name with another adapter', () => {
+ const mine = new Set(a.tools.map((t) => t.name));
+ expect(mine.size).toBe(a.tools.length);
+ for (const name of mine) expect(name).toMatch(/^zoho_books_[a-z_]+$/);
+ for (const meta of listAdapters()) {
+ if (meta.slug === a.slug) continue;
+ for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false);
+ }
+ });
+
+ it('marks every GET read-only, voiding destructive, and installs deletes switched off', () => {
+ const readOnly = a.tools.filter((t) => annotationsOf(t).readOnlyHint === true).map((t) => t.name);
+ expect(readOnly.sort()).toEqual(a.tools.filter((t) => t.endpointMapping.method === 'GET').map((t) => t.name).sort());
+ expect(readOnly.length).toBe(24);
+ const ann = annotationsOf(tool('zoho_books_void_invoice'));
+ expect(`${ann.destructiveHint}:${ann.idempotentHint}`).toBe('true:false');
+ expect(tool('zoho_books_void_invoice').description).toMatch(/confirm/i);
+ expect(tool('zoho_books_email_invoice').description).toMatch(/confirm/i);
+ expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual([
+ 'zoho_books_delete_bill',
+ 'zoho_books_delete_contact',
+ 'zoho_books_delete_customer_payment',
+ 'zoho_books_delete_estimate',
+ 'zoho_books_delete_expense',
+ 'zoho_books_delete_invoice',
+ ]);
+ for (const t of a.tools.filter((x) => x.enabled === false)) expect(annotationsOf(t).destructiveHint).toBe(true);
+ });
+
+ it('only points the model at tools that exist, and writes no em dashes', () => {
+ const names = new Set(a.tools.map((t) => t.name));
+ const mentioned = [
+ ...a.instructions.matchAll(/\bzoho_books_[a-z_]+/g),
+ ...a.tools.flatMap((t) => [...t.description.matchAll(/\bzoho_books_[a-z_]+/g)]),
+ ].map((m) => m[0]);
+ expect(mentioned.length).toBeGreaterThan(10);
+ for (const name of mentioned) expect(names).toContain(name);
+ expect(JSON.stringify(adapter)).not.toMatch(/[–—]/);
+ });
+
+ const table: Array<[string, Record, string, string]> = [
+ ['zoho_books_list_organizations', {}, 'GET', '/organizations'],
+ ['zoho_books_get_organization', {}, 'GET', `/organizations/${ORG}`],
+ ['zoho_books_list_contacts', {}, 'GET', '/contacts'],
+ ['zoho_books_get_contact', { contact_id: '1' }, 'GET', '/contacts/1'],
+ ['zoho_books_create_contact', { contact_name: 'Acme' }, 'POST', '/contacts'],
+ ['zoho_books_update_contact', { contact_id: '1', contact_name: 'Acme', contact_type: 'customer' }, 'PUT', '/contacts/1'],
+ ['zoho_books_mark_contact_inactive', { contact_id: '1' }, 'POST', '/contacts/1/inactive'],
+ ['zoho_books_delete_contact', { contact_id: '1' }, 'DELETE', '/contacts/1'],
+ ['zoho_books_list_items', {}, 'GET', '/items'],
+ ['zoho_books_get_item', { item_id: '2' }, 'GET', '/items/2'],
+ ['zoho_books_create_item', { name: 'Widget', rate: 10 }, 'POST', '/items'],
+ ['zoho_books_update_item', { item_id: '2', name: 'Widget', rate: 12 }, 'PUT', '/items/2'],
+ ['zoho_books_mark_item_inactive', { item_id: '2' }, 'POST', '/items/2/inactive'],
+ ['zoho_books_list_invoices', {}, 'GET', '/invoices'],
+ ['zoho_books_get_invoice', { invoice_id: '3' }, 'GET', '/invoices/3'],
+ ['zoho_books_create_invoice', { customer_id: '1', line_items: [] }, 'POST', '/invoices'],
+ ['zoho_books_email_invoice', { invoice_id: '3', to_mail_ids: ['a@example.com'] }, 'POST', '/invoices/3/email'],
+ ['zoho_books_mark_invoice_sent', { invoice_id: '3' }, 'POST', '/invoices/3/status/sent'],
+ ['zoho_books_void_invoice', { invoice_id: '3' }, 'POST', '/invoices/3/status/void'],
+ ['zoho_books_mark_invoice_draft', { invoice_id: '3' }, 'POST', '/invoices/3/status/draft'],
+ ['zoho_books_delete_invoice', { invoice_id: '3' }, 'DELETE', '/invoices/3'],
+ ['zoho_books_list_estimates', {}, 'GET', '/estimates'],
+ ['zoho_books_get_estimate', { estimate_id: '4' }, 'GET', '/estimates/4'],
+ ['zoho_books_create_estimate', { customer_id: '1', line_items: [] }, 'POST', '/estimates'],
+ ['zoho_books_delete_estimate', { estimate_id: '4' }, 'DELETE', '/estimates/4'],
+ ['zoho_books_list_customer_payments', {}, 'GET', '/customerpayments'],
+ ['zoho_books_get_customer_payment', { payment_id: '5' }, 'GET', '/customerpayments/5'],
+ ['zoho_books_create_customer_payment', { customer_id: '1', amount: 10, date: '2026-10-10', payment_mode: 'cash', invoices: [] }, 'POST', '/customerpayments'],
+ ['zoho_books_delete_customer_payment', { payment_id: '5' }, 'DELETE', '/customerpayments/5'],
+ ['zoho_books_list_bills', {}, 'GET', '/bills'],
+ ['zoho_books_get_bill', { bill_id: '6' }, 'GET', '/bills/6'],
+ ['zoho_books_create_bill', { vendor_id: '7', bill_number: 'B-1', line_items: [] }, 'POST', '/bills'],
+ ['zoho_books_delete_bill', { bill_id: '6' }, 'DELETE', '/bills/6'],
+ ['zoho_books_list_expenses', {}, 'GET', '/expenses'],
+ ['zoho_books_get_expense', { expense_id: '8' }, 'GET', '/expenses/8'],
+ ['zoho_books_create_expense', { account_id: '9', paid_through_account_id: '10', date: '2026-10-10', amount: 5 }, 'POST', '/expenses'],
+ ['zoho_books_delete_expense', { expense_id: '8' }, 'DELETE', '/expenses/8'],
+ ['zoho_books_list_chart_of_accounts', {}, 'GET', '/chartofaccounts'],
+ ['zoho_books_get_account', { account_id: '9' }, 'GET', '/chartofaccounts/9'],
+ ['zoho_books_list_account_transactions', { account_id: '9' }, 'GET', '/chartofaccounts/accounttransactions'],
+ ['zoho_books_list_bank_accounts', {}, 'GET', '/bankaccounts'],
+ ['zoho_books_get_bank_account', { account_id: '10' }, 'GET', '/bankaccounts/10'],
+ ['zoho_books_list_bank_transactions', {}, 'GET', '/banktransactions'],
+ ['zoho_books_get_bank_transaction', { bank_transaction_id: '11' }, 'GET', '/banktransactions/11'],
+ ['zoho_books_list_taxes', {}, 'GET', '/settings/taxes'],
+ ];
+
+ it('lists every tool in the URL table', () => {
+ expect(table.map(([name]) => name).sort()).toEqual(a.tools.map((t) => t.name).sort());
+ });
+
+ it.each(table)('%s sends %s to the right URL', async (name, params, method, path) => {
+ mockedAxios.mockResolvedValue({ data: { code: 0 } });
+ await call(name, params);
+ expect(mockedAxios).toHaveBeenCalledWith(
+ expect.objectContaining({ method, url: `${BASE}${path}`, headers: expect.objectContaining({ Authorization: 'Zoho-oauthtoken test-token' }) }),
+ );
+ if (name !== 'zoho_books_list_organizations') expect(sent().params.organization_id).toBe(ORG);
+ });
+
+ it('creates an invoice with the send flag in the query and the fields in the body', async () => {
+ mockedAxios.mockResolvedValue({ data: { code: 0 } });
+ const lines = [{ item_id: '2', quantity: 1, rate: 12 }];
+ await call('zoho_books_create_invoice', { customer_id: '1', line_items: lines, due_date: '2026-11-01', reference_number: 'PO-7' });
+ expect(sent().params).toEqual({ organization_id: ORG, send: false });
+ expect(sent().data).toEqual({ customer_id: '1', line_items: lines, due_date: '2026-11-01', reference_number: 'PO-7' });
+ });
+
+ it('records a customer payment applied to invoices', async () => {
+ mockedAxios.mockResolvedValue({ data: { code: 0 } });
+ const invoices = [{ invoice_id: '3', amount_applied: 12 }];
+ await call('zoho_books_create_customer_payment', { customer_id: '1', amount: 12, date: '2026-10-10', payment_mode: 'banktransfer', invoices });
+ expect(sent().data).toEqual({ customer_id: '1', amount: 12, date: '2026-10-10', payment_mode: 'banktransfer', invoices });
+ });
+
+ it('pages lists with page and per_page and passes filters through', async () => {
+ mockedAxios.mockResolvedValue({ data: { code: 0 } });
+ await call('zoho_books_list_invoices', { status: 'overdue', customer_id: '1', page: 2, per_page: 50 });
+ expect(sent().params).toEqual({ organization_id: ORG, status: 'overdue', customer_id: '1', page: 2, per_page: 50 });
+ });
+
+ it('turns a non-zero code in a 200 answer into an error and passes code 0 through', async () => {
+ mockedAxios.mockResolvedValue({ data: { code: 1002, message: 'Invoice does not exist.' } });
+ await expect(call('zoho_books_get_invoice', { invoice_id: '3' })).rejects.toBeInstanceOf(ResponseBodyError);
+ mockedAxios.mockResolvedValue({ data: { code: 0, message: 'success', invoice: { invoice_id: '3' } } });
+ await expect(call('zoho_books_get_invoice', { invoice_id: '3' })).resolves.toEqual({ code: 0, message: 'success', invoice: { invoice_id: '3' } });
+ });
+});
+
+const TOKEN = process.env.ZOHO_BOOKS_ACCESS_TOKEN;
+const LIVE_ORG = process.env.ZOHO_BOOKS_ORGANIZATION_ID;
+const live = TOKEN && LIVE_ORG ? describe : describe.skip;
+
+live('zoho-books adapter: live API', () => {
+ const env = { ZOHO_BOOKS_API_URL: process.env.ZOHO_BOOKS_API_URL || EU, ZOHO_BOOKS_ORGANIZATION_ID: LIVE_ORG as string };
+ const write = process.env.ZOHO_BOOKS_LIVE_WRITE === '1';
+ beforeAll(() => {
+ mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any));
+ });
+ const run = (name: string, params: Record = {}): Promise => execute(name, env, TOKEN as string, params);
+ const today = new Date().toISOString().slice(0, 10);
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
+
+ it('reads the organization, contacts, items and sales documents', async () => {
+ const orgs = await run('zoho_books_list_organizations');
+ expect(orgs.organizations.map((o: { organization_id: string }) => o.organization_id)).toContain(LIVE_ORG);
+ const org = await run('zoho_books_get_organization');
+ expect(org.organization.organization_id).toBe(LIVE_ORG);
+ for (const [list, key, get, idKey, param] of [
+ ['zoho_books_list_contacts', 'contacts', 'zoho_books_get_contact', 'contact_id', 'contact_id'],
+ ['zoho_books_list_items', 'items', 'zoho_books_get_item', 'item_id', 'item_id'],
+ ['zoho_books_list_invoices', 'invoices', 'zoho_books_get_invoice', 'invoice_id', 'invoice_id'],
+ ['zoho_books_list_estimates', 'estimates', 'zoho_books_get_estimate', 'estimate_id', 'estimate_id'],
+ ['zoho_books_list_customer_payments', 'customerpayments', 'zoho_books_get_customer_payment', 'payment_id', 'payment_id'],
+ ['zoho_books_list_bills', 'bills', 'zoho_books_get_bill', 'bill_id', 'bill_id'],
+ ['zoho_books_list_expenses', 'expenses', 'zoho_books_get_expense', 'expense_id', 'expense_id'],
+ ]) {
+ const page = await run(list, { per_page: 5 });
+ expect(Array.isArray(page[key])).toBe(true);
+ if (page[key].length) {
+ const one = await run(get, { [param]: page[key][0][idKey] });
+ expect(one.code).toBe(0);
+ }
+ }
+ }, 120_000);
+
+ it('reads the chart of accounts, an account ledger, banking and taxes', async () => {
+ const accounts = await run('zoho_books_list_chart_of_accounts', { showbalance: true });
+ expect(accounts.chartofaccounts.length).toBeGreaterThan(0);
+ const first = accounts.chartofaccounts[0];
+ const one = await run('zoho_books_get_account', { account_id: first.account_id });
+ expect(one.account_id ?? one.chart_of_account?.account_id).toBe(first.account_id);
+ const ledger = await run('zoho_books_list_account_transactions', { account_id: first.account_id, per_page: 5 });
+ expect(ledger.code).toBe(0);
+ const banks = await run('zoho_books_list_bank_accounts');
+ if (banks.bankaccounts?.length) {
+ const bank = await run('zoho_books_get_bank_account', { account_id: banks.bankaccounts[0].account_id });
+ expect(bank.code).toBe(0);
+ }
+ const txs = await run('zoho_books_list_bank_transactions', { per_page: 5 });
+ if (txs.banktransactions?.length) {
+ const tx = await run('zoho_books_get_bank_transaction', { bank_transaction_id: txs.banktransactions[0].transaction_id });
+ expect(tx.code).toBe(0);
+ }
+ const taxes = await run('zoho_books_list_taxes');
+ expect(taxes.code).toBe(0);
+ }, 90_000);
+
+ (write ? it : it.skip)('runs a customer, item, invoice, payment and estimate round-trip and cleans up', async () => {
+ const customer = (
+ await run('zoho_books_create_contact', {
+ contact_name: `AnythingMCP test ${stamp}`,
+ contact_type: 'customer',
+ contact_persons: [{ first_name: 'AnythingMCP', last_name: 'Test', email: 'anythingmcp-test@example.com', is_primary_contact: true }],
+ notes: 'Created by the AnythingMCP live spec; safe to delete.',
+ })
+ ).contact;
+ let itemId: string | undefined;
+ try {
+ const read = await run('zoho_books_get_contact', { contact_id: customer.contact_id });
+ expect(read.contact.contact_name).toBe(customer.contact_name);
+ await run('zoho_books_update_contact', { contact_id: customer.contact_id, contact_name: customer.contact_name, contact_type: 'customer', notes: 'Updated by the live spec' });
+ const found = await run('zoho_books_list_contacts', { search_text: customer.contact_name });
+ expect(found.contacts.map((c: { contact_id: string }) => c.contact_id)).toContain(customer.contact_id);
+
+ const item = (await run('zoho_books_create_item', { name: `AnythingMCP test item ${stamp}`, rate: 10, product_type: 'service' })).item;
+ itemId = item.item_id;
+ expect((await run('zoho_books_get_item', { item_id: item.item_id })).item.rate).toBe(10);
+ await run('zoho_books_update_item', { item_id: item.item_id, name: item.name, rate: 12 });
+
+ const invoice = (await run('zoho_books_create_invoice', { customer_id: customer.contact_id, line_items: [{ item_id: item.item_id, quantity: 1, rate: 12 }], reference_number: 'AnythingMCP test' })).invoice;
+ try {
+ expect((await run('zoho_books_get_invoice', { invoice_id: invoice.invoice_id })).invoice.status).toBe('draft');
+ await run('zoho_books_mark_invoice_sent', { invoice_id: invoice.invoice_id });
+ if (process.env.ZOHO_BOOKS_TEST_EMAIL) {
+ await run('zoho_books_email_invoice', { invoice_id: invoice.invoice_id, to_mail_ids: [process.env.ZOHO_BOOKS_TEST_EMAIL], subject: 'AnythingMCP live spec' });
+ }
+ const listed = await run('zoho_books_list_invoices', { customer_id: customer.contact_id });
+ expect(listed.invoices.map((i: { invoice_id: string }) => i.invoice_id)).toContain(invoice.invoice_id);
+ const payment = (
+ await run('zoho_books_create_customer_payment', {
+ customer_id: customer.contact_id, amount: invoice.total, date: today, payment_mode: 'cash',
+ invoices: [{ invoice_id: invoice.invoice_id, amount_applied: invoice.total }], reference_number: 'AnythingMCP test',
+ })
+ ).payment;
+ try {
+ expect((await run('zoho_books_get_customer_payment', { payment_id: payment.payment_id })).payment.amount).toBe(invoice.total);
+ } finally {
+ await run('zoho_books_delete_customer_payment', { payment_id: payment.payment_id });
+ }
+ await run('zoho_books_void_invoice', { invoice_id: invoice.invoice_id });
+ expect((await run('zoho_books_get_invoice', { invoice_id: invoice.invoice_id })).invoice.status).toBe('void');
+ // A sent invoice cannot be deleted (code 4529), not even void; back to draft first.
+ await expect(run('zoho_books_delete_invoice', { invoice_id: invoice.invoice_id })).rejects.toMatchObject({ response: { status: 400 } });
+ await run('zoho_books_mark_invoice_draft', { invoice_id: invoice.invoice_id });
+ expect((await run('zoho_books_get_invoice', { invoice_id: invoice.invoice_id })).invoice.status).toBe('draft');
+ } finally {
+ const { status } = (await run('zoho_books_get_invoice', { invoice_id: invoice.invoice_id })).invoice;
+ if (status !== 'draft' && status !== 'void') await run('zoho_books_void_invoice', { invoice_id: invoice.invoice_id });
+ if (status !== 'draft') await run('zoho_books_mark_invoice_draft', { invoice_id: invoice.invoice_id });
+ await run('zoho_books_delete_invoice', { invoice_id: invoice.invoice_id });
+ }
+
+ const estimate = (await run('zoho_books_create_estimate', { customer_id: customer.contact_id, line_items: [{ item_id: item.item_id, quantity: 2, rate: 12 }], reference_number: 'AnythingMCP test' })).estimate;
+ try {
+ expect((await run('zoho_books_get_estimate', { estimate_id: estimate.estimate_id })).estimate.customer_id).toBe(customer.contact_id);
+ } finally {
+ await run('zoho_books_delete_estimate', { estimate_id: estimate.estimate_id });
+ }
+ } finally {
+ try {
+ if (itemId) await run('zoho_books_mark_item_inactive', { item_id: itemId });
+ } finally {
+ await run('zoho_books_delete_contact', { contact_id: customer.contact_id });
+ }
+ }
+ }, 240_000);
+
+ (write ? it : it.skip)('records a bill and an expense for a test vendor and deletes them', async () => {
+ const chart = await run('zoho_books_list_chart_of_accounts', { filter_by: 'AccountType.Expense' });
+ const expenseAccount = chart.chartofaccounts.find((x: { account_type: string }) => x.account_type === 'expense') ?? chart.chartofaccounts[0];
+ const all = await run('zoho_books_list_chart_of_accounts', { filter_by: 'AccountType.Active' });
+ const paidThrough = all.chartofaccounts.find((x: { account_type: string }) => x.account_type === 'cash' || x.account_type === 'bank');
+ expect(paidThrough).toBeDefined();
+ const vendor = (await run('zoho_books_create_contact', { contact_name: `AnythingMCP test vendor ${stamp}`, contact_type: 'vendor' })).contact;
+ try {
+ const bill = (
+ await run('zoho_books_create_bill', {
+ vendor_id: vendor.contact_id, bill_number: `AMCP-${stamp}`, date: today,
+ line_items: [{ account_id: expenseAccount.account_id, description: 'AnythingMCP test line', quantity: 1, rate: 5 }],
+ })
+ ).bill;
+ try {
+ expect((await run('zoho_books_get_bill', { bill_id: bill.bill_id })).bill.vendor_id).toBe(vendor.contact_id);
+ } finally {
+ await run('zoho_books_delete_bill', { bill_id: bill.bill_id });
+ }
+ const expense = (
+ await run('zoho_books_create_expense', {
+ account_id: expenseAccount.account_id, paid_through_account_id: paidThrough.account_id, date: today, amount: 3,
+ description: 'AnythingMCP live spec', vendor_id: vendor.contact_id,
+ })
+ ).expense;
+ try {
+ expect((await run('zoho_books_get_expense', { expense_id: expense.expense_id })).expense.total).toBe(3);
+ } finally {
+ await run('zoho_books_delete_expense', { expense_id: expense.expense_id });
+ }
+ } finally {
+ await run('zoho_books_delete_contact', { contact_id: vendor.contact_id });
+ }
+ }, 180_000);
+});
diff --git a/packages/backend/src/mcp-server/error-hints.spec.ts b/packages/backend/src/mcp-server/error-hints.spec.ts
index 7dd55cbe4..9018ac1d7 100644
--- a/packages/backend/src/mcp-server/error-hints.spec.ts
+++ b/packages/backend/src/mcp-server/error-hints.spec.ts
@@ -172,6 +172,23 @@ describe('deriveErrorHint — SQL-backed customer APIs', () => {
).toBeUndefined();
});
+ it('reads Figma\'s "File not found" on dev resources as missing Dev Mode, not a missing file', () => {
+ const read = deriveErrorHint({
+ host: 'api.figma.com',
+ status: 404,
+ body: { error: true, status: 404, message: 'File not found', i18n: 'error.not_found' },
+ });
+ expect(read).toMatch(/Dev Mode/);
+ const write = deriveErrorHint({
+ host: 'api.figma.com',
+ status: 400,
+ message: 'The API answered with an error in a successful response: File not found',
+ body: { links_created: [], errors: [{ file_key: 'k', node_id: '1:2', error: 'File not found' }] },
+ });
+ expect(write).toBe(read);
+ expect(deriveErrorHint({ host: 'api.figma.com', status: 404, body: { status: 404, err: 'Not found' } })).toBeUndefined();
+ });
+
it('tells the user to install the Shopify app when the token answers app_not_installed', () => {
const hint = deriveErrorHint({
host: 'acme.myshopify.com',
diff --git a/packages/backend/src/mcp-server/error-hints.ts b/packages/backend/src/mcp-server/error-hints.ts
index cf2aea0a1..a54d46141 100644
--- a/packages/backend/src/mcp-server/error-hints.ts
+++ b/packages/backend/src/mcp-server/error-hints.ts
@@ -166,6 +166,13 @@ const PRINTFUL_STORE_ID_HINT =
'empty, the account has no store yet: the user must create one in Printful ' +
'(Stores → Add store → "Manual order platform / API") first.';
+const FIGMA_NO_DEV_MODE_HINT =
+ 'Figma answers "File not found" to dev resource calls when the user has no Dev Mode ' +
+ 'access to the file, even though the file exists (a file Figma really cannot find ' +
+ 'answers "Not found"). Dev Mode needs a paid Figma plan and a Full or Dev seat; a ' +
+ 'Starter or locked team, or a View or Collab seat, is refused. Retrying will not help: ' +
+ 'tell the user that dev resources are not available to them on this file.';
+
function bodyText(body: unknown): string {
if (body === undefined || body === null) return '';
if (typeof body === 'string') return body;
@@ -277,6 +284,13 @@ export function deriveErrorHint(input: ErrorHintInput): string | undefined {
return LEXWARE_OVERDUE_HINT;
}
+ // Figma hides a file from the dev resource API when the user lacks Dev Mode:
+ // 404 {"message": "File not found"} on a read, `errors[].error` on a write.
+ // A missing file reads {"err": "Not found"} instead.
+ if (hostMatches(input.host, 'api.figma.com') && /File not found/.test(text)) {
+ return FIGMA_NO_DEV_MODE_HINT;
+ }
+
// TypeSafe answers a question with an unknown type (and any other field it
// does not expect) with a bare "Invalid request.", which names nothing the
// model could fix.
diff --git a/server.json b/server.json
index a64bed1c9..8a8423a63 100644
--- a/server.json
+++ b/server.json
@@ -2,7 +2,7 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.HelpCode-ai/anythingmcp",
"title": "AnythingMCP",
- "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 338 connectors: SAP, ERP.",
+ "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 347 connectors: SAP, ERP.",
"repository": {
"url": "https://github.com/HelpCode-ai/anythingmcp",
"source": "github"