Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ GitHub Releases, or tag-based deployment.

### Changed

- Percent-encode substituted REST path parameters.
- Upgrade query and recipe runtime flow around shared GraphQL/REST execution.
- Move app configuration into `api-agent.toml`.
- Make recipe tools return CSV directly.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ That's it. Agent introspects schema, generates calls, runs SQL post-processing.
}
```

**REST API with header auth (Xquik):**
```json
{
"mcpServers": {
"xquik": {
"url": "http://localhost:3000/mcp",
"headers": {
"X-Target-URL": "https://xquik.com/openapi.json",
"X-API-Type": "rest",
"X-API-Name": "xquik",
"X-Target-Headers": "{\"x-api-key\": \"YOUR_API_KEY\"}"
}
}
}
}
```

Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp.

**Your own API with auth:**
```json
{
Expand Down
12 changes: 10 additions & 2 deletions api_agent/rest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import fnmatch
import logging
from typing import Any
from urllib.parse import urlencode, urljoin
from urllib.parse import quote, urlencode, urljoin

import httpx

Expand All @@ -13,6 +13,14 @@
_UNSAFE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}


def _encode_path_param(value: Any) -> str:
"""Encode one path parameter without leaving URL dot segments."""
encoded = quote(str(value), safe="")
if encoded in {".", ".."}:
return encoded.replace(".", "%2E")
return encoded


def _extract_http_error_details(response: httpx.Response | None) -> Any | None:
"""Extract bounded error detail from non-2xx responses."""
if response is None:
Expand Down Expand Up @@ -76,7 +84,7 @@ def _build_url(
# Substitute path params
if path_params:
for key, value in path_params.items():
path = path.replace(f"{{{key}}}", str(value))
path = path.replace(f"{{{key}}}", _encode_path_param(value))

if not base_url:
raise ValueError("No base URL provided")
Expand Down
18 changes: 18 additions & 0 deletions tests/test_rest_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ def test_list_query_params_use_repeated_keys(self):

assert url == "https://api.example.com/key-results?ids=10&ids=11&cycle=Y2026Q2"

@pytest.mark.parametrize(
("value", "encoded"),
[
("alice/bob + team", "alice%2Fbob%20%2B%20team"),
("München", "M%C3%BCnchen"),
(".", "%2E"),
("..", "%2E%2E"),
],
)
def test_path_params_are_percent_encoded(self, value, encoded):
url = _build_url(
"/users/{user_id}/posts",
"https://api.example.com/v1",
path_params={"user_id": value},
)

assert url == f"https://api.example.com/v1/users/{encoded}/posts"

@pytest.mark.asyncio
async def test_post_allowed_with_matching_path(self):
# POST is allowed when path matches allow_unsafe_paths
Expand Down
77 changes: 77 additions & 0 deletions tests/test_rest_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,83 @@ def test_api_key_auth(self):
ctx = build_schema_context(spec)
assert "apiKey: API key in header 'X-API-Key'" in ctx

def test_xquik_openapi31_search_context(self):
spec = {
"openapi": "3.1.0",
"info": {"title": "Xquik API", "version": "1.0"},
"servers": [{"url": "https://xquik.com"}],
"security": [{"apiKey": []}, {"oauthBearer": []}],
"paths": {
"/api/v1/x/tweets/search": {
"get": {
"operationId": "searchTweets",
"summary": (
"Search tweets by query, Tweet ID, X status URL, or account date window"
),
"parameters": [
{
"name": "q",
"in": "query",
"required": True,
"schema": {"type": "string"},
},
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "default": 20, "maximum": 200},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PaginatedTweets"}
}
}
}
},
}
}
},
"components": {
"schemas": {
"PaginatedTweets": {
"type": "object",
"required": ["tweets", "has_next_page", "next_cursor"],
"properties": {
"tweets": {
"type": "array",
"items": {"$ref": "#/components/schemas/SearchTweet"},
},
"has_next_page": {"type": "boolean"},
"next_cursor": {"type": "string"},
},
},
"SearchTweet": {
"type": "object",
"required": ["id", "text"],
"properties": {
"id": {"type": "string"},
"text": {"type": "string"},
"likeCount": {"type": "integer"},
},
},
},
"securitySchemes": {
"apiKey": {"type": "apiKey", "in": "header", "name": "x-api-key"},
"oauthBearer": {"type": "http", "scheme": "bearer"},
},
},
}
ctx = build_schema_context(spec)
assert "GET /api/v1/x/tweets/search(q: str) -> PaginatedTweets" in ctx
assert "limit" not in ctx
assert "PaginatedTweets { tweets: SearchTweet[]!" in ctx
assert "SearchTweet { id: str!, text: str! }" in ctx
assert "apiKey: API key in header 'x-api-key'" in ctx
assert "oauthBearer: HTTP bearer" in ctx

def test_post_endpoint_with_body(self):
"""POST endpoints show request body type."""
spec = {
Expand Down