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
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Point at any GraphQL or REST API. Ask questions in natural language. The agent f

**🎯 Zero config.** No custom MCP code per API. Point at a GraphQL endpoint, OpenAPI 3.x spec, or Swagger 2.0 spec. The agent introspects/loads schema automatically.

**✨ SQL post-processing.** API returns 10,000 unsorted rows? Agent ranks top 10. No GROUP BY? Agent aggregates. Need to join returned tables? Agent combines. The API doesn't need to support itthe agent does.
**✨ SQL post-processing.** API returns 10,000 unsorted rows? Agent ranks top 10. No GROUP BY? Agent aggregates. Need to join returned tables? Agent combines. The API doesn't need to support it - the agent does.

**🔒 Safe by default.** Read-only. Mutations blocked unless explicitly allowed.

Expand Down 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 Expand Up @@ -308,11 +327,11 @@ flowchart TD
A --> C["Capture successful ordered steps<br/>API and SQL interleaved"]
C --> R["Return query response"]
R --> L{"learn rate sample?"}
L -- no --> Done["Done"]
L -- yes --> X["Structured extractor<br/>public contract + private plan"]
L -->|no| Done["Done"]
L -->|yes| X["Structured extractor<br/>public contract + private plan"]
X --> V{"candidate result<br/>matches original result?"}
V -- no --> Done
V -- yes --> Store["Store recipe<br/>fingerprint dedupe"]
V -->|no| Done
V -->|yes| Store["Store recipe<br/>fingerprint dedupe"]
Store --> List["Next list_tools exposes r_{slug}"]

subgraph Direct["Direct recipe call"]
Expand Down Expand Up @@ -360,7 +379,7 @@ Mapped REST dependency shape:
- Recipe names use concise `snake_case` action-resource slugs. Descriptions are outcome-focused and do not inject API labels or implementation step counts.
- If multiple recipes share the same slug, the most recently used one is exposed.
- Tool args are **flat top-level fields** (not nested under `params`), all required, and must be user-intent inputs.
- Clients call these tools directly no LLM reasoning, just cached API+SQL pipeline.
- Clients call these tools directly - no LLM reasoning, just cached API+SQL pipeline.

For normal `{prefix}_query` calls, recipe reuse is still agent-mediated: matching recipes are exposed as tools and prompt hints, then the agent decides whether to call one. If the agent uses a recipe tool, that run is not learned again, and the recipe tool returns directly by default. Direct `r_{slug}` calls bypass the agent entirely and always return directly as CSV.

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