Skip to content
Merged
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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ docker run -p 3000:3000 -e OPENAI_API_KEY="..." api-agent

- **api_agent/utils/**: Shared utilities
- **csv.py**: CSV conversion via DuckDB (for recipe `return_directly` output)
- **http_errors.py**: HTTP error response extraction (used by both clients)

- **api_agent/graphql/**: GraphQL client (httpx)
- **api_agent/rest/**: REST client (httpx) + OpenAPI loader
- **api_agent/rest/**: REST client (httpx) + OpenAPI loader (supports OpenAPI 3.x and Swagger 2.0)
- **api_agent/executor.py**: DuckDB SQL execution, table extraction, context truncation

### Context Management
Expand Down Expand Up @@ -131,6 +132,16 @@ Query → Agent executes → Extractor LLM → Recipe stored → MCP tool `r_{na
- **Templating**: GraphQL `{{param}}`, REST `{"$param": "name"}`, SQL `{{param}}`
- **Config**: `ENABLE_RECIPES` (default: True), `RECIPE_CACHE_SIZE` (default: 64)

## After Code Changes

Always run before marking task complete:
```bash
uv run ruff check --fix api_agent/ # Lint + auto-fix
uv run ruff format api_agent/ # Format
uv run ty check # Type check
uv run pytest tests/ -v # Tests
```

## Testing Notes

Tests use pytest-asyncio. Mock httpx for HTTP calls. See `tests/test_*.py` for patterns.
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ That's it. Agent introspects schema, generates queries, runs SQL post-processing

## More Examples

**REST API (Petstore):**
**REST API (Petstore — OpenAPI 3.x):**
```json
{
"mcpServers": {
Expand All @@ -71,6 +71,21 @@ That's it. Agent introspects schema, generates queries, runs SQL post-processing
}
```

**REST API (Petstore — Swagger 2.0):**
```json
{
"mcpServers": {
"petstore": {
"url": "http://localhost:3000/mcp",
"headers": {
"X-Target-URL": "https://petstore.swagger.io/v2/swagger.json",
"X-API-Type": "rest"
}
}
}
}
```

**Your own API with auth:**
```json
{
Expand All @@ -95,7 +110,7 @@ That's it. Agent introspects schema, generates queries, runs SQL post-processing

| Header | Required | Description |
| ---------------------- | -------- | ---------------------------------------------------------- |
| `X-Target-URL` | Yes | GraphQL endpoint OR OpenAPI spec URL |
| `X-Target-URL` | Yes | GraphQL endpoint OR OpenAPI/Swagger spec URL (3.x and 2.0) |
| `X-API-Type` | Yes | `graphql` or `rest` |
| `X-Target-Headers` | No | JSON auth headers, e.g. `{"Authorization": "Bearer xxx"}` |
| `X-API-Name` | No | Override tool name prefix (default: auto-generated) |
Expand Down
10 changes: 9 additions & 1 deletion api_agent/agent/graphql_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,11 @@ async def graphql_query(query: str, name: str = "data", return_directly: bool =
indent=2,
)

if not result.get("success"):
result["hint"] = (
"Use search_schema to find valid field names, enum values, or required args"
)

return json.dumps(result, indent=2)

return graphql_query
Expand Down Expand Up @@ -463,7 +468,10 @@ def _create_individual_recipe_tools(
tool_name = deduplicate_tool_name(s.get("tool_name", "unknown_recipe"), seen_names)
params_spec = recipe.get("params", {})
docstring = build_recipe_docstring(
s["question"], recipe.get("steps", []), recipe.get("sql_steps", []), "graphql",
s["question"],
recipe.get("steps", []),
recipe.get("sql_steps", []),
"graphql",
params_spec=params_spec,
)

Expand Down
4 changes: 3 additions & 1 deletion api_agent/agent/rest_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,9 @@ def _create_individual_recipe_tools(
tool_name = deduplicate_tool_name(s.get("tool_name", "unknown_recipe"), seen_names)
params_spec = recipe.get("params", {})
docstring = build_recipe_docstring(
s["question"], recipe.get("steps", []), recipe.get("sql_steps", []),
s["question"],
recipe.get("steps", []),
recipe.get("sql_steps", []),
params_spec=params_spec,
)

Expand Down
4 changes: 3 additions & 1 deletion api_agent/graphql/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import httpx

from ..utils.http_errors import build_http_error_response

logger = logging.getLogger(__name__)

# Block mutations (read-only mode)
Expand Down Expand Up @@ -57,7 +59,7 @@ async def execute_query(
return {"success": False, "error": result["errors"]}
return {"success": True, "data": result.get("data", {})}
except httpx.HTTPStatusError as e:
return {"success": False, "error": f"HTTP {e.response.status_code}"}
return build_http_error_response(e)
except Exception as e:
logger.exception("GraphQL error")
return {"success": False, "error": str(e)}
4 changes: 3 additions & 1 deletion api_agent/recipe/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,9 @@ def build_recipe_docstring(
if params_spec:
param_lines = []
for pname, spec in params_spec.items():
ptype = _JSON_TYPE_NAMES.get(spec.get("type", "str") if isinstance(spec, dict) else "str", "string")
ptype = _JSON_TYPE_NAMES.get(
spec.get("type", "str") if isinstance(spec, dict) else "str", "string"
)
example = spec.get("default") if isinstance(spec, dict) else None
hint = f" (e.g. {example})" if example is not None else ""
param_lines.append(f" {pname}: {ptype} REQUIRED{hint}")
Expand Down
12 changes: 3 additions & 9 deletions api_agent/rest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import httpx

from ..utils.http_errors import build_http_error_response

logger = logging.getLogger(__name__)

# Unsafe HTTP methods (blocked by default)
Expand Down Expand Up @@ -138,15 +140,7 @@ async def execute_request(
return {"success": True, "data": data}

except httpx.HTTPStatusError as e:
# Try to get error body
try:
error_body = e.response.json()
except Exception:
error_body = e.response.text[:500]
return {
"success": False,
"error": f"HTTP {e.response.status_code}: {error_body}",
}
return build_http_error_response(e)
except Exception as e:
logger.exception("REST API error")
return {"success": False, "error": str(e)}
Loading
Loading