diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97206c2..ff03c75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 03d59ba..0956c9b 100644
--- a/README.md
+++ b/README.md
@@ -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 it—the 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.
@@ -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
{
@@ -308,11 +327,11 @@ flowchart TD
A --> C["Capture successful ordered steps
API and SQL interleaved"]
C --> R["Return query response"]
R --> L{"learn rate sample?"}
- L -- no --> Done["Done"]
- L -- yes --> X["Structured extractor
public contract + private plan"]
+ L -->|no| Done["Done"]
+ L -->|yes| X["Structured extractor
public contract + private plan"]
X --> V{"candidate result
matches original result?"}
- V -- no --> Done
- V -- yes --> Store["Store recipe
fingerprint dedupe"]
+ V -->|no| Done
+ V -->|yes| Store["Store recipe
fingerprint dedupe"]
Store --> List["Next list_tools exposes r_{slug}"]
subgraph Direct["Direct recipe call"]
@@ -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.
diff --git a/api_agent/rest/client.py b/api_agent/rest/client.py
index 44fe3ad..46bf48d 100644
--- a/api_agent/rest/client.py
+++ b/api_agent/rest/client.py
@@ -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
@@ -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:
@@ -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")
diff --git a/tests/test_rest_client.py b/tests/test_rest_client.py
index 8a72ebe..58b8d69 100644
--- a/tests/test_rest_client.py
+++ b/tests/test_rest_client.py
@@ -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
diff --git a/tests/test_rest_schema.py b/tests/test_rest_schema.py
index 30614d8..7eca965 100644
--- a/tests/test_rest_schema.py
+++ b/tests/test_rest_schema.py
@@ -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 = {