From 530e5aaa6c8c64ea614990c85669d4cfd72aa501 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sat, 4 Jul 2026 14:41:34 +0300 Subject: [PATCH 1/4] test: add Xquik REST schema example --- README.md | 17 +++++++++ tests/test_rest_schema.py | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/README.md b/README.md index 03d59ba..908ba7a 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,23 @@ 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\"}" + } + } + } +} +``` + **Your own API with auth:** ```json { 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 = { From 9ef0e61433bd1d97495aadc44b71aad719365472 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sat, 18 Jul 2026 06:23:38 +0300 Subject: [PATCH 2/4] Fix REST path encoding and document Xquik --- CHANGELOG.md | 1 + README.md | 14 ++++++++------ api_agent/rest/client.py | 4 ++-- tests/test_rest_client.py | 9 +++++++++ 4 files changed, 20 insertions(+), 8 deletions(-) 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 908ba7a..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. @@ -103,6 +103,8 @@ That's it. Agent introspects schema, generates calls, runs SQL post-processing. } ``` +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 { @@ -325,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"] @@ -377,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..7e19c78 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 @@ -76,7 +76,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}}}", quote(str(value), safe="")) 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..be4a32a 100644 --- a/tests/test_rest_client.py +++ b/tests/test_rest_client.py @@ -73,6 +73,15 @@ def test_list_query_params_use_repeated_keys(self): assert url == "https://api.example.com/key-results?ids=10&ids=11&cycle=Y2026Q2" + def test_path_params_are_percent_encoded(self): + url = _build_url( + "/users/{user_id}/posts", + "https://api.example.com/v1", + path_params={"user_id": "alice/bob + team"}, + ) + + assert url == "https://api.example.com/v1/users/alice%2Fbob%20%2B%20team/posts" + @pytest.mark.asyncio async def test_post_allowed_with_matching_path(self): # POST is allowed when path matches allow_unsafe_paths From 129e87e4258d96b4e77d0264f0dabd715a6acde7 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 19 Jul 2026 15:06:15 +0300 Subject: [PATCH 3/4] Harden encoded REST path parameters --- api_agent/rest/client.py | 10 +++++++++- tests/test_rest_client.py | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/api_agent/rest/client.py b/api_agent/rest/client.py index 7e19c78..46bf48d 100644 --- a/api_agent/rest/client.py +++ b/api_agent/rest/client.py @@ -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}}}", quote(str(value), safe="")) + 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 be4a32a..58b8d69 100644 --- a/tests/test_rest_client.py +++ b/tests/test_rest_client.py @@ -73,14 +73,23 @@ def test_list_query_params_use_repeated_keys(self): assert url == "https://api.example.com/key-results?ids=10&ids=11&cycle=Y2026Q2" - def test_path_params_are_percent_encoded(self): + @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": "alice/bob + team"}, + path_params={"user_id": value}, ) - assert url == "https://api.example.com/v1/users/alice%2Fbob%20%2B%20team/posts" + assert url == f"https://api.example.com/v1/users/{encoded}/posts" @pytest.mark.asyncio async def test_post_allowed_with_matching_path(self): From 65bb9540b4b37f3a2afdca4c1c20a7d889f12291 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Thu, 30 Jul 2026 02:29:26 +0300 Subject: [PATCH 4/4] docs: restore unrelated README formatting --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0956c9b..911854a 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. @@ -327,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"] @@ -379,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.