arcade-mcp/toolkits/zendesk/arcade_zendesk/tools/search_articles.py
Eric Gustin 5228c75dc9
Add ToolMetadata to OSS toolkits (#776)
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 -->
2026-02-25 09:41:41 -08:00

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