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 = {