Resolves TOO-388 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Primarily metadata/dependency additions with no changes to core tool execution paths; risk is limited to potential packaging/import issues from the new `arcade-mcp-server` dependency. > > **Overview** > Adds `ToolMetadata` to tool decorators across the Bright Data, ClickHouse, MongoDB, Postgres, LinkedIn, Zendesk, and Math toolkits, specifying *behavior* (read-only/idempotency/destructive/open-world) and, where applicable, *service domain* classification. > > Updates each toolkit package to depend on `arcade-mcp-server` (plus local `uv` source wiring) and bumps toolkit versions accordingly; minor `__all__` ordering tweaks in Math/Zendesk are included. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3bde3a061194e1d1b6a4e8a2ebd608b17984db4f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
219 lines
7.1 KiB
Python
219 lines
7.1 KiB
Python
import logging
|
|
from typing import Annotated, Any
|
|
|
|
import httpx
|
|
from arcade_mcp_server.metadata import (
|
|
Behavior,
|
|
Classification,
|
|
Operation,
|
|
ServiceDomain,
|
|
ToolMetadata,
|
|
)
|
|
from arcade_tdk import ToolContext, tool
|
|
from arcade_tdk.auth import OAuth2
|
|
from arcade_tdk.errors import RetryableToolError
|
|
|
|
from arcade_zendesk.enums import ArticleSortBy, SortOrder
|
|
from arcade_zendesk.utils import (
|
|
fetch_paginated_results,
|
|
get_zendesk_subdomain,
|
|
process_search_results,
|
|
validate_date_format,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@tool(
|
|
requires_auth=OAuth2(id="zendesk", scopes=["read"]),
|
|
requires_secrets=["ZENDESK_SUBDOMAIN"],
|
|
metadata=ToolMetadata(
|
|
classification=Classification(
|
|
service_domains=[ServiceDomain.CUSTOMER_SUPPORT],
|
|
),
|
|
behavior=Behavior(
|
|
operations=[Operation.READ],
|
|
read_only=True,
|
|
destructive=False,
|
|
idempotent=True,
|
|
open_world=True,
|
|
),
|
|
),
|
|
)
|
|
async def search_articles(
|
|
context: ToolContext,
|
|
query: Annotated[
|
|
str | None,
|
|
"Search text to match against articles. Supports quoted expressions for exact matching",
|
|
] = None,
|
|
label_names: Annotated[
|
|
list[str] | None,
|
|
"List of label names to filter by (case-insensitive). Article must have at least "
|
|
"one matching label. Available on Professional/Enterprise plans only",
|
|
] = None,
|
|
created_after: Annotated[
|
|
str | None,
|
|
"Filter articles created after this date (format: YYYY-MM-DD)",
|
|
] = None,
|
|
created_before: Annotated[
|
|
str | None,
|
|
"Filter articles created before this date (format: YYYY-MM-DD)",
|
|
] = None,
|
|
created_at: Annotated[
|
|
str | None,
|
|
"Filter articles created on this exact date (format: YYYY-MM-DD)",
|
|
] = None,
|
|
sort_by: Annotated[
|
|
ArticleSortBy | None,
|
|
"Field to sort articles by. Defaults to relevance according to the search query",
|
|
] = None,
|
|
sort_order: Annotated[
|
|
SortOrder | None,
|
|
"Sort order direction. Defaults to descending",
|
|
] = None,
|
|
limit: Annotated[
|
|
int,
|
|
"Number of articles to return. Defaults to 30",
|
|
] = 30,
|
|
offset: Annotated[
|
|
int,
|
|
"Number of articles to skip before returning results. Defaults to 0",
|
|
] = 0,
|
|
include_body: Annotated[
|
|
bool,
|
|
"Include article body content in results. Bodies will be cleaned of HTML and truncated",
|
|
] = True,
|
|
max_article_length: Annotated[
|
|
int | None,
|
|
"Maximum length for article body content in characters. "
|
|
"Set to None for no limit. Defaults to 500",
|
|
] = 500,
|
|
) -> Annotated[
|
|
dict[str, Any],
|
|
"Article search results with pagination metadata. Includes 'next_offset' when more "
|
|
"results are available. Simply use this value as the 'offset' parameter in your next "
|
|
"call to fetch the next batch",
|
|
]:
|
|
"""
|
|
Search for Help Center articles in your Zendesk knowledge base.
|
|
|
|
This tool searches specifically for published knowledge base articles that provide
|
|
solutions and guidance to users. At least one search parameter (query or label_names)
|
|
must be provided.
|
|
|
|
PAGINATION:
|
|
- The response includes 'next_offset' when more results are available
|
|
- To fetch the next batch, simply pass the 'next_offset' value as the 'offset' parameter
|
|
- If 'next_offset' is not present, you've reached the end of available results
|
|
- The tool automatically handles fetching from the correct page based on your offset
|
|
|
|
IMPORTANT: ALL FILTERS CAN BE COMBINED IN A SINGLE CALL
|
|
You can combine multiple filters (query, labels, dates) in one search request.
|
|
Do NOT make separate tool calls - combine all relevant filters together.
|
|
"""
|
|
|
|
# Validate date parameters
|
|
date_params = {
|
|
"created_after": created_after,
|
|
"created_before": created_before,
|
|
"created_at": created_at,
|
|
}
|
|
|
|
for param_name, param_value in date_params.items():
|
|
if param_value and not validate_date_format(param_value):
|
|
raise RetryableToolError(
|
|
message=(
|
|
f"Invalid date format for {param_name}: '{param_value}'. "
|
|
"Please use YYYY-MM-DD format."
|
|
),
|
|
developer_message=(
|
|
f"Date validation failed for parameter '{param_name}' "
|
|
f"with value '{param_value}'"
|
|
),
|
|
retry_after_ms=500,
|
|
additional_prompt_content="Use format YYYY-MM-DD.",
|
|
)
|
|
|
|
# Validate limit and offset parameters
|
|
if limit < 1:
|
|
raise RetryableToolError(
|
|
message="limit must be at least 1.",
|
|
developer_message=f"Invalid limit value: {limit}",
|
|
retry_after_ms=100,
|
|
additional_prompt_content="Provide a positive limit value",
|
|
)
|
|
|
|
if offset < 0:
|
|
raise RetryableToolError(
|
|
message="offset cannot be negative.",
|
|
developer_message=f"Invalid offset value: {offset}",
|
|
retry_after_ms=100,
|
|
additional_prompt_content="Provide a non-negative offset value",
|
|
)
|
|
|
|
# Validate that at least one search parameter is provided
|
|
if not any([query, label_names]):
|
|
raise RetryableToolError(
|
|
message="At least one search parameter must be provided.",
|
|
developer_message="No search parameters were provided",
|
|
retry_after_ms=100,
|
|
additional_prompt_content=(
|
|
"Provide at least one of: query text or a list of label names"
|
|
),
|
|
)
|
|
|
|
auth_token = context.get_auth_token_or_empty()
|
|
subdomain = get_zendesk_subdomain(context)
|
|
|
|
url = f"https://{subdomain}.zendesk.com/api/v2/help_center/articles/search"
|
|
|
|
# Base parameters for the search
|
|
base_params: dict[str, Any] = {
|
|
"per_page": 100, # Max allowed per page
|
|
}
|
|
|
|
if query:
|
|
base_params["query"] = query
|
|
|
|
if label_names:
|
|
base_params["label_names"] = ",".join(label_names)
|
|
|
|
if created_after:
|
|
base_params["created_after"] = created_after
|
|
|
|
if created_before:
|
|
base_params["created_before"] = created_before
|
|
|
|
if created_at:
|
|
base_params["created_at"] = created_at
|
|
|
|
if sort_by:
|
|
base_params["sort_by"] = sort_by.value
|
|
|
|
if sort_order:
|
|
base_params["sort_order"] = sort_order.value
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
headers = {
|
|
"Authorization": f"Bearer {auth_token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
data = await fetch_paginated_results(
|
|
client=client,
|
|
url=url,
|
|
headers=headers,
|
|
params=base_params,
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
|
|
if "results" in data:
|
|
data["results"] = process_search_results(
|
|
data["results"], include_body=include_body, max_body_length=max_article_length
|
|
)
|
|
|
|
logger.info(f"Article search returned {data.get('count', 0)} results")
|
|
|
|
return data
|