Spall
Break free. Hit the endpoint.
spall is a dynamic CLI tool that parses OpenAPI 3.x specifications at runtime and generates fully-featured command-line interfaces for making API requests — with validation, auth, colored output, and schema-aware help.
A spall is the fragment that breaks free from a corroding metal surface and flies. Your request — shaped by the spec, launched from the terminal, sent across the gap.
Think Restish, but Rust.
Features
- Dynamic CLI from OpenAPI specs — no codegen required.
- Runtime spec loading from file path or URL.
- Two-phase parsing for fast startup and rich per-operation help.
- Schema-aware argument validation and typed flags.
- Colored, formatted response output with TTY detection.
- Request history, shell completions, pagination, REPL shell, and JMESPath filtering.
Quick Usage
# Register an API
spall api add petstore https://petstore.swagger.io/v2/swagger.json
# List operations
spall petstore --help
# Make a request
spall petstore get-pet-by-id 1
# POST with a body
spall petstore add-pet --data '{"name":"Rex","status":"available"}'
Next Steps
- Installation — download and set up spall.
- Your First Request — register an API and make a call.
- Key Concepts — understand how spall works.
Installation
Spall ships as a single static binary with no runtime dependencies.
Download
Pre-built binaries are available for Linux, macOS, and Windows on the releases page.
# Linux / macOS
curl -sL https://github.com/rustpunk/spall/releases/latest/download/spall-$(uname -s)-$(uname -m) -o spall
chmod +x spall
sudo mv spall /usr/local/bin/
# Verify
spall --version
Build from Source
Requires Rust 1.80+.
git clone https://github.com/rustpunk/spall.git
cd spall
cargo build --release --workspace
# Binary will be at:
# ./target/release/spall
Shell Completion Setup
Generate completion scripts for your shell:
# Bash
spall completions bash > /etc/bash_completion.d/spall
# Zsh
spall completions zsh > "${fpath[1]}/_spall"
# Fish
spall completions fish > ~/.config/fish/completions/spall.fish
Next Steps
- Your First Request — register an API and make a call.
Your First Request
This walkthrough registers the classic Petstore API, explores its operations, and makes a few requests.
1. Register the API
spall api add petstore https://petstore.swagger.io/v2/swagger.json
Spall will fetch the spec, cache it locally, and build an internal index. You only need to do this once.
2. List registered APIs
spall api list
Output:
Registered APIs:
petstore https://petstore.swagger.io/v2/swagger.json
3. Explore operations
spall petstore --help
Spall loads the spec and prints every operation, grouped by OpenAPI tags. Operation names are derived from operationId (kebab-cased) or synthesized from the HTTP method and path.
4. Make a GET request
Path parameters become positional arguments. Query parameters become --flags.
# GET /pet/{petId}
spall petstore get-pet-by-id 1
If the terminal is a TTY, the response is pretty-printed JSON with syntax highlighting. If piped, raw JSON is emitted.
5. Make a POST request
Post a JSON body with --data:
spall petstore add-pet --data '{"name":"Rex","status":"available"}'
You can also read body data from a file or stdin:
spall petstore add-pet --data @new-pet.json
# or
cat new-pet.json | spall petstore add-pet --data -
6. Override the server
Point the same operation at a staging server without re-registering:
spall petstore get-pet-by-id 1 --spall-server https://staging.petstore.io
7. Dry-run and preview
See what spall will send without hitting the network:
spall petstore get-pet-by-id 1 --spall-dry-run
Or preview the fully resolved request (URL, headers, body):
spall petstore add-pet --data '{"name":"Rex"}' --spall-preview
What just happened
- Phase 1 — spall scanned your config registry and matched
petstoreas a registered API name. - Phase 2 — spall loaded the cached spec, resolved all
$refs, merged parameters, and built a dynamic clap command tree. - Execution — spall validated your inputs against the schema, built the HTTP request, sent it, and formatted the response.
Next Steps
- Key Concepts — understand the mental model behind spall.
- Registering APIs — deep dive on API management.
- Making Requests — all the ways to call an operation.
Key Concepts
Two-Phase Parse
Spall uses a two-phase argument parser so that --help feels instant even when the underlying spec is large.
Phase 1 — Index scan (~1ms) reads only your config files (~/.config/spall/config.toml, apis/*.toml, and any spec_dirs). It registers each API as a clap subcommand stub with disable_help_flag(true) so that --help falls through.
Phase 2 — Spec load (~50–200ms) happens only when you actually invoke an API. The spec is loaded from cache or fetched from its source, $refs are resolved, and a full clap command tree is built. If the spec is unreachable, spall falls back to a lightweight cached SpecIndex so you still see the operation list.
Dynamic Command Building
Unlike tools that generate static Rust code from a spec, spall constructs clap Command and Arg objects at runtime. This means:
- No recompilation when an API changes.
- Schema enums become clap
possible_values. - Defaults from the spec become clap
default_value. - No generated code bloat.
Parameter Namespacing
OpenAPI allows a path param id and a query param id on the same operation. Spall namespaces them internally while preserving user-facing names:
OpenAPI in | Internal ID | User-facing |
|---|---|---|
| path | path-id | positional argument |
| query | query-id | --id |
| header | header-id | --header-id |
| cookie | cookie-id | --cookie-id |
IR Cache
After the first parse, spall serializes the resolved spec to a compact binary format (postcard) keyed by SHA-256 of the raw spec bytes. On subsequent runs it skips YAML/JSON parsing and $ref resolution entirely. Cache invalidation is automatic when the spec content changes or when spall upgrades its IR format.
Credential Stack
Auth resolution follows a strict priority chain so that scripts, CI, and interactive sessions can all coexist:
--spall-authCLI override- Per-API config
[auth]section viatoken_url/password_url(hasp),token_env/password_env, or inlinetoken(warned) - Legacy global
SPALL_<API>_TOKENenv var (Wave 1–2 compat) - Interactive password prompt for Basic (TTY)
All credentials are wrapped in secrecy::SecretString — they are zeroized on drop and redacted from debug output and history.
Exit Codes
Spall returns structured exit codes so scripts can branch on outcome:
| Code | Meaning |
|---|---|
| 0 | Success (2xx response) |
| 1 | CLI usage error |
| 2 | Network / connection error |
| 3 | Spec loading / parsing error |
| 4 | HTTP 4xx response |
| 5 | HTTP 5xx response |
| 10 | Request body / parameter validation failed |
Next Steps
Registering APIs
Before you can call an API, spall needs to know where its OpenAPI 3.x spec lives.
Add an API
spall api add <name> <source>
The source can be a local file path or a URL:
# Local file
spall api add internal ./specs/internal-api.yaml
# Remote URL
spall api add petstore https://petstore.swagger.io/v2/swagger.json
This creates ~/.config/spall/apis/{name}.toml. The spec is fetched, cached, and indexed immediately.
List registered APIs
spall api list
Output:
Registered APIs:
petstore https://petstore.swagger.io/v2/swagger.json
internal ./specs/internal-api.yaml
Remove an API
spall api remove petstore
This deletes ~/.config/spall/apis/petstore.toml and invalidates the IR cache.
Refresh a cached spec
Remote specs are cached with a TTL and conditional GET (ETag). If the spec has changed on the server, refresh it:
# Refresh one API
spall api refresh petstore
# Refresh everything
spall api refresh --all
Refresh also invalidates the IR cache so the next request rebuilds it from the new spec.
Discover an API from a base URL
If a server advertises its OpenAPI spec via RFC 8631 service-desc link relation, spall can probe and auto-register:
spall api discover https://api.example.com
Spall will follow Link: <...spec...>; rel="service-desc" headers, derive a name from the spec title, and register it just like api add.
Auto-scanning spec directories
You can point spall at a directory full of spec files instead of registering each one manually:
# ~/.config/spall/config.toml
spec_dirs = [
"~/.config/spall/specs",
]
Files in these directories are auto-registered. Names are derived from the filename minus extension:
| Filename | Registered name |
|---|---|
petstore.json | petstore |
my-internal-api.yaml | my-internal-api |
v2_billing.yml | v2-billing |
Priority (highest → lowest):
apis/*.tomlfiles[[api]]inline entries inconfig.tomlspec_dirsscanned files
Lower-priority entries with duplicate names are discarded.
Next Steps
Making Requests
Once an API is registered, every OpenAPI operation becomes a subcommand. Parameter types, enums, and defaults are preserved from the spec.
Path Parameters
Path parameters are supplied as positional arguments in the order they appear in the path template.
Given a path /pets/{petId}:
spall petstore get-pet-by-id 1
Path parameters are always required. If you omit one, clap prints a usage error before any network activity occurs.
Query Parameters
Query parameters become --flags. Given an operation with in: query params status and limit:
spall petstore find-pets-by-status --status available --limit 10
If the spec declares an enum for status, spall registers it as clap possible_values so typos are caught immediately:
spall petstore find-pets-by-status --status availbale
# error: invalid value 'availbale' for '--status'
# [possible values: available, pending, sold]
Defaults from the spec are honored:
# If limit defaults to 20 in the spec, this is equivalent:
spall petstore find-pets-by-status --status available
Header Parameters
Header parameters become --header-{name} flags:
spall petstore create-order --header-x-request-id abc-123
Cookie Parameters
Cookie parameters become --cookie-{name} flags:
spall petstore login --cookie-session abc123
Spall collects all cookie params and sends them as a single Cookie header.
Injecting Arbitrary Headers
For headers not declared in the spec, use --spall-header (repeatable):
spall petstore get-pet-by-id 1 --spall-header "X-Custom: value" --spall-header "X-Another: 42"
Overriding the Server URL
Use --spall-server to target a different base URL for a single request:
spall petstore get-pet-by-id 1 --spall-server https://staging.petstore.io
The resolution order is:
--spall-serverCLI flag- Per-API config
base_url - Operation-level
serversfrom the spec - Spec-level
servers - Fallback
/
Deprecation Warnings
If an operation is marked deprecated: true in the spec, spall prints a [DEPRECATED] banner in the help text. The operation still works — this is a heads-up, not a gate.
Next Steps
Request Bodies
When an operation declares a request body, spall adds --data, --form, and --field arguments. They are mutually exclusive.
JSON Body with --data
The default for JSON APIs:
spall petstore add-pet --data '{"name":"Rex","status":"available"}'
From a file
Prefix the path with @:
spall petstore add-pet --data @new-pet.json
From stdin
Use -:
cat new-pet.json | spall petstore add-pet --data -
Optional body
If the spec says the body is not required, spall also registers --no-data so you can skip it explicitly:
spall petstore update-pet --no-data
Content-Type override
If the spec supports multiple content types, spall defaults to application/json. Override with --spall-content-type:
spall petstore upload-spec --data @spec.yaml --spall-content-type text/yaml
Multipart Upload with --form
For multipart/form-data uploads, use --form (repeatable):
spall petstore upload-file --form file=@image.png --form description="avatar"
File values are auto-detected by the @ prefix and streamed as binary parts.
URL-Encoded with --field
For application/x-www-form-urlencoded, use --field (repeatable):
spall oauth token --field grant_type=client_credentials --field client_id=abc
Validation
Before the request is sent, spall validates the body against the operation’s request schema (when application/json is declared). Validation errors are printed to stderr and spall exits with code 10.
spall petstore add-pet --data '{"status":"invalid"}'
# Validation failed:
# /body/name: required property 'name' is missing
Next Steps
Response Output
Spall automatically chooses an output format based on whether stdout is a TTY.
Default Behavior
| Context | Format |
|---|---|
| TTY (interactive terminal) | Pretty-printed JSON with syntax highlighting |
| Pipe / file redirect | Raw JSON |
# Pretty JSON (TTY)
spall petstore get-pet-by-id 1
# Raw JSON (piped)
spall petstore get-pet-by-id 1 | jq '.name'
Output Modes
Use --spall-output to override:
# Raw JSON
spall petstore get-pet-by-id 1 --spall-output raw
# YAML
spall petstore get-pet-by-id 1 --spall-output yaml
# Table (requires a JSON array of objects)
spall petstore find-pets-by-status --status available --spall-output table
# CSV (requires a JSON array of objects)
spall petstore find-pets-by-status --status available --spall-output csv
Table and CSV modes walk the array and collect all unique top-level keys as headers. If the response is not a JSON array, spall warns and falls back to pretty JSON.
Saving Responses
Save the response body to a file without touching stdout:
spall petstore get-pet-by-id 1 --spall-download pet.json
Or use the @file syntax with --spall-output:
spall petstore get-pet-by-id 1 --spall-output @pet.json
Binary responses are streamed raw to the file. When writing to stdout on a TTY, spall emits a warning and suggests --spall-download.
Filtering with JMESPath
Extract fields without installing jq:
spall petstore find-pets-by-status --status available --filter "[].name"
# ["Rex","Fluffy"]
If the filter expression is invalid, spall warns and falls back to the unfiltered response.
Verbose Mode
Print request and response headers to stderr:
spall petstore get-pet-by-id 1 --spall-verbose
With --spall-time, the duration is included:
spall petstore get-pet-by-id 1 --spall-verbose --spall-time
Sensitive headers (Authorization, Cookie, X-Api-Key, etc.) are redacted.
Next Steps
Authentication
Quick Pass-Through with --spall-auth
For one-off testing, pass a token or credentials directly:
# Bearer token
spall github get-user octocat --spall-auth "Bearer ghp_xxxxxxxx"
# Basic auth (user:pass)
spall internal get-data --spall-auth "Basic alice:secret"
# Shorthand basic (no space, one colon)
spall internal get-data --spall-auth "alice:secret"
# Bare token (treated as Bearer)
spall github get-user octocat --spall-auth "ghp_xxxxxxxx"
--spall-auth is the highest-priority auth source. It overrides everything else for that single request.
Environment Variables
If --spall-auth is omitted, spall looks for SPALL_<API>_TOKEN (hyphens become underscores):
export SPALL_GITHUB_TOKEN=ghp_xxxxxxxx
spall github get-user octocat
Per-API Config
Store auth settings in ~/.config/spall/apis/{name}.toml:
source = "https://api.example.com/openapi.json"
base_url = "https://api.example.com"
[auth]
kind = "Bearer"
token_env = "MY_API_TOKEN"
Supported kind values: Bearer, Basic, ApiKey, OAuth2.
API Key
[auth]
kind = "ApiKey"
token_env = "MY_API_KEY"
location = "header" # or "query"
header_name = "X-Api-Key" # ignored when location = "query"
query_name = "api_key" # ignored when location = "header"
Basic Auth
[auth]
kind = "Basic"
username = "alice"
password_env = "ALICE_PASSWORD"
If password_env is not set and stdin is a TTY, spall prompts for the password interactively.
Bearer with Secret URL
[auth]
kind = "Bearer"
token_url = "keyring://spall/github-token"
OAuth2 (Authorization Code + PKCE)
For APIs that require interactive browser login, configure the IDP endpoints:
[auth]
kind = "OAuth2"
client_id = "your-app-client-id"
auth_url = "https://idp.example.com/oauth/authorize"
token_url = "https://idp.example.com/oauth/token"
scopes = ["read:user", "repo"]
Then run spall auth login <api> once:
$ spall auth login github
Open the following URL in your browser to authorize spall:
https://idp.example.com/oauth/authorize?response_type=code&client_id=...
Waiting for the authorization callback on 127.0.0.1:53217 ...
Successfully signed in to 'github'. Tokens stored locally.
Spall binds a one-shot loopback listener (random port), receives the OAuth2 callback,
exchanges the authorization code at token_url using the PKCE verifier, and persists
the access_token + refresh_token to $XDG_CACHE_HOME/spall/oauth2/<api>.json
(mode 0600). On every subsequent request spall refreshes the access token
automatically when it is within 30 seconds of expiry; if the refresh fails you’ll
be asked to run spall auth login again.
Note: OAuth2 tokens are session state owned by spall — they live in the cache dir, not in
hasp. Theauth.token_urlfield has a different meaning for theOAuth2kind (the IDP token endpoint) than forBearer/ApiKey(a hasp URL).
Secret URLs with hasp
Spall integrates with hasp for fetching secrets from multiple backends via URL-style references. This is the recommended way to manage credentials. The default build includes three backends:
| Field | Auth Kinds | Example URL |
|---|---|---|
token_url | Bearer, ApiKey | env://MY_TOKEN, file:~/secrets/api.key, keyring://spall/api-token |
password_url | Basic | env://ALICE_PASSWORD, file:/run/secrets/password, keyring://spall/alice-pass |
client_secret_url | OAuth2 (confidential clients) | env://CLIENT_SECRET |
For
kind = "OAuth2",token_urlis the IDP token endpoint (e.g.https://idp.example/oauth/token), not a hasp URL. See “OAuth2 (Authorization Code + PKCE)” above.
URL Schemes
| Scheme | Format | Use Case |
|---|---|---|
env:// | env://VAR_NAME | CI, Docker, local overrides |
file:// | file:///absolute/path or file:~/relative | Kubernetes secret mounts, dotfiles |
keyring:// | keyring://service/entry | macOS Keychain, GNOME Keyring, Windows Credential Manager |
hasp is enabled by default. If you need additional backends (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, 1Password, Bitwarden), build with the hasp-full feature:
cargo install spall-cli --features hasp-full
Examples
Fetch a Bearer token from the OS keyring:
[auth]
kind = "Bearer"
token_url = "keyring://spall/github-token"
Read an API key from a file mount (Docker / Kubernetes):
[auth]
kind = "ApiKey"
token_url = "file:///run/secrets/api_key"
location = "header"
header_name = "X-Api-Key"
Read a Basic password from an environment variable:
[auth]
kind = "Basic"
username = "alice"
password_url = "env://ALICE_PASSWORD"
Inline Tokens (Discouraged)
You can embed a token directly in the config file:
[auth]
kind = "Bearer"
token = "ghp_xxxxxxxx"
Spall will accept it but prints a warning recommending token_url or token_env instead.
Credential Hygiene
All credentials are wrapped in secrecy::SecretString:
- Memory is zeroized on drop.
- Debug output prints
[REDACTED]. - History redacts sensitive headers (
Authorization,Cookie,X-Api-Key, etc.). --spall-debugwire logs also redact secrets.
Next Steps
Pagination
Many APIs paginate large result sets via Link headers (RFC 5988). Spall can auto-follow these links and concatenate the results into a single JSON array.
Basic Usage
spall github list-repos --spall-paginate
Spall sends the initial request, inspects the Link header for rel="next", and follows it until:
- No
nextlink is present. - The maximum page limit (default 100) is reached.
- A non-2xx response is returned (spall exits with the appropriate code).
Result Concatenation
If every page is a JSON array, all elements are flattened into one array:
// Page 1: [{"id":1},{"id":2}]
// Page 2: [{"id":3},{"id":4}]
// Output: [{"id":1},{"id":2},{"id":3},{"id":4}]
If a page is not an array, it is wrapped as a single item:
// Page 1: [{"id":1}]
// Page 2: {"meta": {...}}
// Output: [{"id":1},{"meta": {...}}]
Combining with Output and Filtering
Pagination works with all output modes and filtering:
spall github list-repos --spall-paginate --spall-output csv
spall github list-repos --spall-paginate --filter "[].full_name"
Limitations
--spall-paginatecannot be combined with--form(multipart uploads).- Pagination requires JSON responses. If a page returns non-JSON, spall exits with a usage error.
Next Steps
Global Flags
All internal spall flags use the --spall-* prefix so they never collide with API parameters.
Output and Formatting
| Flag | Short | Description |
|---|---|---|
--spall-output | -O | Output format: json/pretty, raw, yaml, table, csv, or @file |
--spall-download | -o | Save response body to a file |
--spall-verbose | -v | Print request/response headers to stderr |
--spall-debug | Wire-level debug logging (redacts secrets) | |
--spall-time | Include request/response timing in verbose output | |
--filter | JMESPath filter expression for JSON responses |
Network Control
| Flag | Short | Description |
|---|---|---|
--spall-server | -s | Override base URL for this request |
--spall-timeout | -t | Timeout in seconds (default: 30) |
--spall-retry | Retry count for failed requests (default: 1, max: 3) | |
--spall-redirect | -L | Follow HTTP 3xx redirects (default: off) |
--spall-max-redirects | Maximum redirects (default: 10) | |
--spall-insecure | Skip TLS certificate verification | |
--spall-ca-cert | Path to custom CA certificate (PEM or DER) | |
--spall-cert | Path to client certificate PEM (mTLS); requires --spall-key | |
--spall-key | Path to client private key PEM (mTLS); requires --spall-cert | |
--spall-proxy | HTTP/SOCKS proxy URL | |
--spall-no-proxy | Disable proxy for this request |
Request Modification
| Flag | Short | Description |
|---|---|---|
--spall-header | -H | Inject a non-sensitive header (repeatable) |
--spall-auth | -A | Pass-through auth token/header |
--spall-content-type | -c | Override request content type |
Execution Control
| Flag | Short | Description |
|---|---|---|
--spall-dry-run | Print curl equivalent without executing | |
--spall-preview | Show resolved URL, headers, and body without sending | |
--spall-paginate | Auto-follow Link header pagination | |
--spall-follow <REL> | Follow a hypermedia link (RFC 5988 Link header / HAL / JSON:API / Siren) once after a successful response | |
--spall-retry-max-wait | Maximum seconds to honor a Retry-After header before giving up (default: 60) | |
--spall-repeat | Replay the most recent request from history | |
--spall-chain | JMESPath chain expression for multi-stage requests | |
--profile | Active config profile (e.g., staging, production) |
Examples
Verbose request with timing
spall petstore get-pet-by-id 1 --spall-verbose --spall-time
Staging override with custom header
spall petstore get-pet-by-id 1 \
--spall-server https://staging.petstore.io \
--spall-header "X-Debug: true"
Retry with redirect following
spall petstore get-pet-by-id 1 --spall-retry 3 --spall-redirect
Hypermedia link following
After a successful response, --spall-follow <rel> looks up that rel
across the Link header (RFC 5988), HAL _links, JSON:API links, and
Siren links, then issues a GET to the link’s target and returns its body
in place of the original.
spall github list-repos --spall-follow next
spall hal-api get-order 1 --spall-follow self
mTLS to a private API
spall corp list-things \
--spall-ca-cert ./corp-ca.pem \
--spall-cert ./client.pem \
--spall-key ./client-key.pem
The CA cert may be PEM or DER. The client cert and key must both be PEM; pass them as separate files.
Replay last request
spall --spall-repeat
spall history show 42 --spall-repeat
Next Steps
Config Layout
Spall stores all configuration under your platform’s config directory (typically ~/.config/spall/ on Linux, ~/Library/Application Support/spall/ on macOS, %APPDATA%\spall\ on Windows).
Directory Structure
~/.config/spall/
├── config.toml # Global settings
├── apis/
│ ├── github.toml # Per-API overrides
│ └── petstore.toml
├── specs/ # Optional auto-scan directory
│ ├── internal-api.yaml
│ └── partner-api.json
└── cache/
├── <hash>.raw # Cached remote spec bytes
├── <hash>.raw-meta # TTL + ETag metadata
├── <hash>.ir # Compiled IR (postcard)
├── <hash>.idx # Lightweight SpecIndex
├── <hash>.meta # IR cache metadata (SHA-256 + version)
└── history.db # SQLite request history
Global Config (config.toml)
# Register APIs inline
[[api]]
name = "github"
spec = "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json"
[[api]]
name = "petstore"
spec = "https://petstore.swagger.io/v2/swagger.json"
# Auto-scan directories
spec_dirs = [
"~/.config/spall/specs",
]
```toml
[defaults]
output = "json" # json | pretty | raw | yaml | table | csv
color = "auto" # auto | always | never
[defaults.proxy]
url = "http://proxy:8080"
Note: Spall rejects unknown fields in config.toml and per-API apis/*.toml files. If you see a deserialization error, check for typos in field names.
Per-API Config (apis/{name}.toml)
Created automatically by spall api add. You can edit these to add auth or overrides:
source = "https://petstore.swagger.io/v2/swagger.json"
base_url = "https://staging.petstore.io"
[auth]
kind = "Bearer"
token_url = "env://PETSTORE_TOKEN"
[headers]
X-Client = "spall-cli"
[profiles]
[profiles.staging]
base_url = "https://staging.petstore.io"
[profiles.production]
base_url = "https://petstore.io"
The auth table supports inline tokens, environment variables, and hasp secret URLs (env://, file://, keyring://). See Authentication for the full priority chain and URL scheme reference.
Per-API fields
| Field | Type | Description |
|---|---|---|
source | string | Spec file path or URL (required) |
base_url | string | Override the spec’s server URL |
proxy | string | HTTP/SOCKS proxy URL for this API |
auth | table | Auth configuration (tokens, env vars, hasp secret URLs) |
headers | table | Headers added to every request |
profiles | table | Named environment overlays |
Cache
Spall manages the cache/ directory automatically. You should not need to edit these files directly.
- Raw cache (
*.raw,*.raw-meta) stores fetched spec bytes with TTL and ETag for conditional GET. Proxy settings are respected during spec fetches. - IR cache (
*.ir) stores the resolved spec in postcard format for instant reload. - Index cache (
*.idx) stores a lightweightSpecIndexfor degraded--helpwhen the spec is unreachable. - History (
history.db) is a SQLite database of recent requests.
Next Steps
Profiles
Profiles let you switch between environments (staging, production, local) without re-registering the API or editing files before every command.
Defining Profiles
Add a [profiles.{name}] section to any per-API config file:
# ~/.config/spall/apis/petstore.toml
source = "https://petstore.swagger.io/v2/swagger.json"
base_url = "https://petstore.io"
[auth]
kind = "Bearer"
token_env = "PETSTORE_TOKEN"
[profiles.staging]
base_url = "https://staging.petstore.io"
auth = { kind = "Bearer", token_env = "PETSTORE_STAGING_TOKEN" }
[profiles.production]
base_url = "https://petstore.io"
Using Profiles
Activate a profile with --profile:
# Hit staging
spall petstore get-pet-by-id 1 --profile staging
# Hit production
spall petstore get-pet-by-id 1 --profile production
Profile Overlay Rules
When a profile is active, its values override the base config:
base_urlreplaces the base config value entirely.authreplaces the base config auth entirely.proxyreplaces the base config proxy value entirely.headersare merged: profile headers with the same key override base headers; new keys are appended.
If the requested profile does not exist, spall exits with a usage error.
Next Steps
Proxy Support
Spall routes requests through HTTP and SOCKS proxies. Proxies can be configured via CLI flags, per-API config files, global defaults, or standard environment variables.
Resolution Priority
Spall resolves the effective proxy URL using the following priority (highest first):
--spall-no-proxy— disables proxying entirely for the request.--spall-proxy <url>— CLI override.- Per-API
proxyin~/.config/spall/apis/{name}.toml. - Profile
proxyin the active per-API profile. - Global default
proxyin~/.config/spall/config.toml. - Environment variables —
HTTPS_PROXY, thenHTTP_PROXY, thenALL_PROXY. - Direct connection — no proxy.
CLI Flags
# Use a proxy for this request only
spall petstore get-pet-by-id 1 --spall-proxy http://proxy:8080
# Disable proxy for this request (ignore config/env)
spall petstore get-pet-by-id 1 --spall-no-proxy
# SOCKS5 proxy
spall petstore get-pet-by-id 1 --spall-proxy socks5://localhost:1080
Config File
Global default proxy
# ~/.config/spall/config.toml
[defaults.proxy]
url = "http://proxy.corp.internal:8080"
Per-API proxy
# ~/.config/spall/apis/internal.toml
source = "https://api.internal.example/openapi.json"
proxy = "http://proxy.corp.internal:8080"
Profile-level proxy
# ~/.config/spall/apis/internal.toml
source = "https://api.internal.example/openapi.json"
[profiles.staging]
proxy = "http://staging-proxy:8080"
[profiles.production]
proxy = "http://prod-proxy:8080"
Environment Variables
Spall reads the standard proxy environment variables when no config or CLI value is set:
| Variable | Description |
|---|---|
HTTPS_PROXY | Preferred for HTTPS destinations |
HTTP_PROXY | Fallback |
ALL_PROXY | Fallback for any protocol |
NO_PROXY | Comma-separated list of hosts to bypass |
export HTTPS_PROXY=http://proxy:8080
export NO_PROXY=localhost,127.0.0.1,.local
# Direct — proxy is skipped for localhost and *.local
cd spall petstore get-pet-by-id 1
NO_PROXY format
*— bypass all proxies.example.com— exact match..example.com— matchesexample.comand*.example.com.- Values are case-insensitive.
Authentication
Embed credentials directly in the proxy URL:
--spall-proxy http://user:password@proxy:8080
Note: The password is passed directly to
reqwestand is not logged by spall. Use--spall-debugwith care; reqwest may still include the URL in wire logs.
Next Steps
CLI Reference
Top-Level Commands
spall [OPTIONS] <COMMAND>
| Command | Description |
|---|---|
spall api ... | Manage registered APIs |
spall auth ... | Authentication commands |
spall history ... | Request/response history |
spall completions ... | Generate shell completion scripts |
spall repl | Start the interactive REPL shell |
spall <api> <operation> [args] | Execute an API operation |
spall api
| Subcommand | Description |
|---|---|
spall api add <name> <source> | Register a new API |
spall api list | List registered APIs |
spall api remove <name> | Unregister an API |
spall api refresh [<name>] | Refresh cached remote spec |
spall api refresh --all | Refresh all cached remote specs |
spall api discover <url> | Discover and register an API via RFC 8631 |
spall auth
| Subcommand | Description |
|---|---|
spall auth status <api> | Show auth status for an API |
spall auth login <api> | Initiate OAuth2 PKCE login (stub) |
spall history
| Subcommand | Description |
|---|---|
spall history list | List recent requests |
spall history show <id> | Show full request details |
spall history search | Search history with filters |
spall history clear | Erase all history |
Global Flags
See Global Flags for the full list of --spall-* options.
Next Steps
Request History
Spall records every request to a local SQLite database (cache/history.db). This is useful for debugging, auditing, and replay.
Listing History
spall history list
Output:
42 2025-04-25 14:32 GET 200 124ms petstore get-pet-by-id
41 2025-04-25 14:30 POST 201 312ms petstore add-pet
40 2025-04-25 14:28 GET 404 89ms github get-repo
Searching History
Filter by API name, status code, method, URL substring, or date:
spall history search --api petstore --status 200 --limit 5
spall history search --method POST --since 2025-04-01
spall history search --url "/pets/" --limit 10
All filters are optional and combined with AND logic. --since accepts dates in YYYY-MM-DD format.
Viewing a Single Request
spall history show 42
This prints the method, URL, status code, duration, request headers, and response headers. Sensitive headers are redacted.
Replaying a Request
# Replay the most recent request
spall --spall-repeat
# Replay a specific request by ID
spall history show 42 --spall-repeat
Replay reconstructs the exact method, URL, headers, and body from the history record, then re-executes it.
Clearing History
spall history clear
This deletes all rows and vacuums the database.
Privacy Notes
- Request and response headers are recorded, but sensitive headers (
Authorization,Cookie,X-Api-Key, etc.) are stored as[REDACTED]. - Request bodies are not stored in history.
- The history database is local to your machine and never transmitted.
Next Steps
Interactive REPL
Spall includes an embedded REPL shell that keeps the registry resident in memory and lets you issue multiple commands without the ~50-200ms spec-load overhead on each call.
Starting the REPL
spall repl
spall REPL — type 'help' for commands, 'quit' or 'exit' to leave.
spall>
Commands Inside the REPL
Any input that is not a special command is parsed as a normal spall command (without the spall prefix):
spall> api list
Registered APIs:
petstore https://petstore.swagger.io/v2/swagger.json
spall> petstore get-pet-by-id 1
{ ... }
spall> petstore get-pet-by-id 1 --spall-output csv
id,name,status
1,Rex,available
Special Commands
| Command | Description |
|---|---|
help | Show available commands |
history | List the last 20 recorded requests |
quit / exit | Leave the REPL |
Ctrl-C interrupts the current prompt without exiting. Ctrl-D sends EOF and exits.
REPL History
Command history is saved to ~/.cache/spall/repl_history (or your platform equivalent) across sessions. Use the Up arrow to recall previous inputs.
When to Use It
The REPL is useful for:
- Interactive API exploration — tab-like speed without tab-like setup.
- Poking at an API while debugging — no repeated spec fetches.
- Batch scripting small exploratory sequences without shell function wrappers.
Request Chaining
The REPL supports pipe syntax to chain requests, passing the JSON response from one stage into the next via JMESPath expressions:
spall> petstore get-pet-by-id 1 | update-pet --id id --status status
Each stage after the first is a chain expression: operation --arg jmespath_expr .... The response from stage N-1 becomes the input for stage N.
If a pipe stage fails, the REPL prints a structured error showing the stage number, the failing expression, and a debug suggestion.
Limitations
- The REPL uses
rustylinefor line editing. Complex multi-line JSON payloads should be written to a file and passed with@file. - OAuth2 PKCE interactive flows are not yet implemented inside the REPL.
Next Steps
Arazzo Workflows
Arazzo 1.0.1 is the
OpenAPI Initiative’s standard for describing multi-step API workflows —
“log in, capture the token, use it to fetch the user, assert the user is
active.” Spall ships a v1 runner that reads .arazzo.yaml files,
resolves their sourceDescriptions against the existing IR cache, and
executes each step through the same request pipeline used by every
other spall command.
Status: v1, single-direction (stops on first failure). Failure actions, nested workflows,
replay, regex/jsonpath criteria, and the--spall-bindCLI override are tracked in issue #5.
Subcommands
spall arazzo run <file>
[--input key=value] repeatable; populates $inputs.<key>
[--workflow id] choose when the doc has >1 workflow
[--dry-run] print resolved requests, send nothing
[--output json|yaml] serialization for final workflow outputs (stdout)
[--verbose] emit a source-binding banner at workflow start
spall arazzo validate <file>
# parses the doc and reports any v2-only constructs it found
Workflow stdout is only the final outputs object — pipe-clean for
jq, shell read, or downstream tools. Progress, warnings, and
dry-run details go to stderr.
A minimal workflow
# onboard.arazzo.yaml
arazzo: 1.0.1
info:
title: Onboard a new customer
version: 1.0.0
sourceDescriptions:
- name: api
url: ./customer-openapi.json
type: openapi
workflows:
- workflowId: createAndFetch
inputs:
type: object
properties:
email: { type: string }
steps:
- stepId: createUser
operationId: createUser
requestBody:
contentType: application/json
payload:
email: $inputs.email
successCriteria:
- condition: $response.statusCode == 201
outputs:
user_id: $response.body#/id
- stepId: fetchUser
operationId: getUser
parameters:
- name: id
in: path
value: $steps.createUser.outputs.user_id
successCriteria:
- condition: $response.statusCode == 200
outputs:
email: $response.body#/email
outputs:
user_id: $steps.createUser.outputs.user_id
email: $steps.fetchUser.outputs.email
spall arazzo run ./onboard.arazzo.yaml --input email=alice@example.com
Output (stdout, JSON):
{
"outputs": { "email": "alice@example.com", "user_id": "user-42" },
"steps": [ … ],
"workflowId": "createAndFetch"
}
The expression dialect
Step parameters, request-body fields, success criteria, and outputs all
accept Arazzo expressions. The runner evaluates any string that starts
with $; everything else is treated as a literal.
| Expression | Resolves to |
|---|---|
$inputs.email | The --input email=... value |
$workflow.inputs.region | Alias for $inputs.region |
$steps.<id>.outputs.<name> | A named output from an earlier step |
$steps.<id>.response.body#/path/to/field | RFC 6901 JSON Pointer into the step body |
$steps.<id>.response.header.X-Request-Id | Response header (RFC 9110 case-insensitive) |
$steps.<id>.response.statusCode | Integer status code |
$response.body#/foo (only in outputs/criteria) | Current step’s response body |
$response.header.<Name> | Current step’s response headers |
$response.statusCode | Current step’s status code |
When an expression appears inside a JSON request body, the runner walks
the structure and replaces every string leaf that starts with $. A
non-string leaf (true, 42, an array, an object) passes through
unchanged.
Runtime conditions for successCriteria
Each successCriteria[].condition is one of:
- A bare expression that the runner asserts is truthy. The falsy
values are
false,null,0,"",[], and{}. - A binary comparison
<lhs> <op> <rhs>where each operand is an expression or a literal (number,true/false/null, or a quoted string), and<op>is one of==,!=,<,<=,>,>=.
successCriteria:
- condition: $response.statusCode == 200
- condition: $response.body#/status == "ready"
- condition: $steps.create.outputs.count > 0
- condition: $response.body#/items # truthy if the array is non-empty
< / <= / > / >= require both operands to coerce to a number;
otherwise the step fails with operand cannot be coerced to a number.
Binding sources to spall APIs
Each sourceDescription names an OpenAPI spec. The runner resolves the
URL (file path or http(s)://) through spall’s existing fetch +
IR-cache pipeline, then binds the source to a configured spall API
entry so it can reuse the API’s auth chain. Resolution order:
- Explicit override: if the source description has
x-spall-api: <api-name>, bind to that spall API. - Name match: if
<source.name>matches a registeredspall api, bind to it. - Synthetic: otherwise, synthesize a bare API entry — requests run unauthenticated. The runner emits a stderr warning with the exact fix-it command.
sourceDescriptions:
- name: petstore
url: https://petstore3.swagger.io/api/v3/openapi.json
type: openapi
# default: binds to `spall api petstore` if it exists.
- name: internal
url: ./internal-api.json
type: openapi
x-spall-api: prod # bind to the configured `spall api prod` instead
If a step receives a 401 or 403 from an unbound source, the error message tells you exactly how to fix it:
step 'getMe': step 'getMe' returned HTTP 401 — source 'petstore' is
unbound; try: spall api add petstore https://… && spall auth login petstore
What runs unauthenticated
When the source is synthetic (no matching spall API entry), the runner does not attach any Authorization header. Workflows that target a public API or local mock are fine; workflows that target an authenticated API need either:
spall api add petstore https://example.com/openapi.json
spall auth login petstore # set up creds
spall arazzo run ./workflow.arazzo.yaml # auth chain runs automatically
…or an x-spall-api: <existing-api> extension on the source
description.
Validation
spall arazzo validate <file> parses the document and surfaces:
- Errors — the doc declares no workflows, or a step has neither
operationIdnoroperationPathnorworkflowId. Exit code 10. - Warnings — the doc uses
operationPath(v2),workflowId(v2, nested workflows), or a non-simplesuccessCriteria.type(v2, regex/jsonpath). Exit code 0; the runner will skip these constructs at execution time. Tracked in issue #5.
$ spall arazzo validate ./onboard.arazzo.yaml
ok: './onboard.arazzo.yaml' parses cleanly; all v1 features supported (1 workflow, 1 source).
Dry-run
--dry-run parses the workflow, evaluates expressions, and prints each
step’s resolved request (method, URL, headers, body) to stderr — but
sends nothing. Useful for sanity-checking expression bindings before a
real run hits an external system.
$ spall arazzo run ./onboard.arazzo.yaml --input email=a@b.test --dry-run
[dry-run] step 'createUser': POST https://example.com/customers
header: {"Content-Type": "application/json"}
body: {"email":"a@b.test"}
Failure actions
Step and workflow-level action chains let a workflow recover from
HTTP failures and unmet successCriteria per Arazzo §4.6 / §4.7.
Three action types are supported:
type: end
Stops the workflow. Exit code depends on which side fired:
- Success-side
type:end(inonSuccessorsuccessActions) → exit 0 with workflow outputs. - Failure-side
type:end(inonFailureorfailureActions) → exit non-zero with workflow + step attribution in stderr. The workflow took its failure branch and CI pipelines need to surface that.
steps:
- stepId: probe
operationId: getProbeStatus
onFailure:
- name: known-4xx
type: end
criteria:
- condition: $response.statusCode == 404
If no criteria are listed, the action fires unconditionally. To
absorb a known-OK 4xx into a zero exit, use type: goto to a
cleanup step (see below), not type: end on the failure side.
type: retry
Sleeps for retryAfter seconds then re-runs the current step, up to
retryLimit times. When the limit is reached, the workflow exits
non-zero with the last error attached:
steps:
- stepId: callFlakyAPI
operationId: getThing
onFailure:
- name: try-again
type: retry
retryAfter: 0.5
retryLimit: 3
retryLimit defaults to 1 if omitted; retryAfter defaults to 0.
The runner clamps each retry sleep at 60 seconds — a buggy spec with
retryAfter: 999999 cannot hang the workflow indefinitely. The
retry counter does NOT compose with --spall-retry (the HTTP-transport
retry layer); they’re orthogonal.
type: retry also fires on transport errors (DNS / connection-reset /
TLS handshake fails), not just HTTP 4xx/5xx — that’s the exact case
backoff exists for.
type: goto
Jumps to the named step. workflowId (cross-workflow goto) is a v2
feature and rejects at runtime if used:
steps:
- stepId: probe
operationId: probe
onFailure:
- name: recover
type: goto
stepId: cleanupStep
- stepId: shouldBeSkipped
operationId: probe
- stepId: cleanupStep
operationId: cleanup
Workflow-level fallback
workflow.successActions and workflow.failureActions apply to every
step that doesn’t define its own onSuccess / onFailure chain.
Step-level absence vs explicit-empty matter:
onFailurefield absent on the step → workflow-level applies.onFailure: []on the step → opt out of the workflow-level default, no actions fire on failure (the underlying error bubbles up).onFailure: [...]non-empty → step-level wins; workflow-level is not consulted.
workflows:
- workflowId: paranoid
failureActions:
- name: bail
type: end
steps:
- stepId: a
operationId: a-op # workflow-level 'bail' applies
- stepId: b
operationId: b-op
onFailure: [] # opts out of workflow-level
Reusable named actions
Heavy uses can centralize actions under components:
components:
failureActions:
bail:
name: bail
type: end
workflows:
- workflowId: x
steps:
- stepId: probe
operationId: probe
onFailure:
- reference: $components.failureActions.bail
Reference paths must be exactly
$components.successActions.<name> or
$components.failureActions.<name> — typos error at workflow-start
time so a malformed reference doesn’t silently fall through.
Criterion type
Action criteria reuse the same condition mini-language as
successCriteria. v1 supports only type: simple (the default);
jsonpath and regex are deferred to issue #5 and error hard at
dispatch time so partial implementations don’t sneak in via fixtures.
A non-empty context field — only used by v2 jsonpath/regex — also
errors hard so it can’t be confused with simple-mode evaluation.
Step budget
spall arazzo run --spall-max-steps N caps the total number of step
executions per workflow (default 10000). The counter increments on
every step body run including retries and goto-revisits. A goto X
from step X with always-true criteria — the textbook infinite-loop
shape — bails with StepBudgetExhausted once the counter overshoots.
JSON output shape
spall arazzo run --output json (the default) emits one workflow
record to stdout per run:
{
"workflowId": "loginAndUseToken",
"outputs": { /* workflow-level outputs */ },
"steps": [
{
"stepId": "doLogin",
"status": 200,
"dryRun": false,
"outputs": { "token": "Bearer abc123" }
// failedVia omitted — step completed normally
},
{
"stepId": "maybeFail",
"status": 500,
"dryRun": false,
"outputs": {},
"failedVia": "on-failure-goto" // absorbed via goto recovery
}
]
}
Per-step fields:
| Field | Type | Meaning |
|---|---|---|
stepId | string | The step’s stepId from the .arazzo.yaml. |
status | integer | HTTP status of the step’s response, or 0 if no HTTP call ran (criteria-only fail). |
dryRun | boolean | true when the step body was skipped due to --dry-run. |
outputs | object | Values from the step’s outputs: expressions. Empty when the step failed. |
failedVia | string | absent | Only present when an onFailure action absorbed the step. Values: "on-failure-end", "on-failure-goto". Consumers use this to distinguish an absorbed-failure step from a true success. |
v1 limitations
| Feature | v1 | Tracking |
|---|---|---|
failureActions / successActions (workflow + step level) | Implemented (this release) | — |
onSuccess / onFailure actions | Implemented (this release) | — |
$components.successActions / $components.failureActions refs | Implemented (this release) | — |
workflowId (nested) | Errors at runtime | issue #5 |
replay action | Not implemented | issue #5 |
operationPath | Errors at runtime | issue #5 |
successCriteria.type: regex / jsonpath | Skipped with a warning | issue #5 |
Action criteria.type: regex / jsonpath | Errors at dispatch | issue #5 |
--spall-bind <source>=<api> CLI override | Use x-spall-api extension | issue #5 |
| Inputs JSON Schema validation | None — values are opaque strings | issue #5 |
Anything not in the table is in scope for v1.
MCP Server
spall mcp <api> serves every operation in a registered OpenAPI spec as
a Model Context Protocol tool over stdio. Drop the binary
into a Claude Desktop or ChatGPT Apps config and the AI client can call
your API with no integration code.
What it does
Given an API you’ve already added with spall api add petstore <spec-url>:
spall mcp petstore # serves on stdio, default transport
Each ResolvedOperation becomes one MCP tool. The tool’s inputSchema
is generated from the operation’s parameters and request body; on
tools/call, spall dispatches through the same request pipeline used by
spall <api> <op> (auth chain, default headers, proxy, retries).
Wire it into Claude Desktop with this entry in
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"spall-petstore": {
"command": "spall",
"args": ["mcp", "petstore"]
}
}
}
Restart Claude; the tools appear in the sidebar.
Usage
spall mcp <api>
[--spall-transport stdio|http]
[--spall-port <N>] # HTTP only; default 8765
[--spall-bind <addr>] # HTTP only; default 127.0.0.1
[--spall-allowed-origin <origin>] # HTTP only; repeatable
[--spall-include <tag>] # repeatable
[--spall-exclude <tag>] # repeatable
[--spall-max-tools <N>]
[--spall-list-tags]
--spall-transportselects the wire protocol:stdio(default) for Claude Desktop / config-launched servers.httpfor Streamable HTTP per MCP spec 2025-06-18 §HTTP; see Running over HTTP below.
--spall-include <tag>keeps only operations carrying that OpenAPI tag (repeatable; union semantics).--spall-exclude <tag>removes operations carrying that tag.--spall-max-tools <N>deterministically truncates the filtered registry toNtools when the spec exceeds the cap. See Sizing your server for the ordering rule.--spall-list-tagsloads the spec, prints atag\tcount\tsample-op-idTSV to stdout, and exits without starting the server. Useful for crafting an--spall-includefilter.- Operations with no
tagsbelong to a synthetic tag nameddefault— you can include/exclude them by that name.
Tool naming
Tool names come straight from the operation’s operationId, sanitized
to fit MCP’s allowed character class ([A-Za-z0-9_./-], max 64 chars,
lowercased). For example:
operationId | tool name |
|---|---|
getPetById | getpetbyid |
create user | create-user |
Foo::Bar | foo-bar |
If two sanitized names collide (extremely rare; the resolver
deduplicates operationId collisions on load), spall appends -2,
-3, etc.
Auth
tools/call runs the standard spall auth resolution chain (env var →
hasp → OAuth2 stored token → config field). You must configure
credentials out-of-band before starting the server; MCP gives no
opportunity to prompt interactively.
Per-tool auth profiles
Some APIs mix public-read and admin-write endpoints, or carry separate
keychain entries per operation class. Two surfaces let you pin
specific tools to a non-default [profile.*] block from the API’s
config:
spall mcp github \
--spall-auth-tool delete-repo=admin \
--spall-auth-tool transfer-repo=admin
The flag is repeatable; <tool> matches either the sanitized tool
name from tools/list or the raw operationId from the spec.
Equivalently, declare the binding inline on the operation in your
spec via the extension x-mcp-auth-profile:
paths:
/repos/{id}:
delete:
operationId: delete-repo
x-mcp-auth-profile: admin
...
When both forms target the same tool, the CLI flag wins.
Profiles named via either path are validated at server start; an unknown profile name aborts startup with the list of configured profiles so typos surface immediately.
Tool annotations
Each entry in tools/list carries an annotations block with
client-confirmation hints derived from the HTTP method
(MCP spec 2025-06-18 §tools):
| Method | readOnlyHint | destructiveHint | idempotentHint |
|---|---|---|---|
| GET / HEAD / OPTIONS / TRACE | true | false | true |
| PUT / DELETE | false | true | true |
| PATCH | false | true | false |
| POST | (omitted) | (omitted) | (omitted) |
POST is intentionally hint-free — the server cannot infer intent.
Override any hint with the operation-level x-mcp-annotations
extension, which merges field-by-field over the derived defaults:
paths:
/search:
post:
operationId: search
x-mcp-annotations:
readOnlyHint: true # POST that is in fact read-only
idempotentHint: true
...
Unknown keys (e.g. openWorldHint) pass through so future MCP spec
additions don’t require a spall release.
The title annotation is auto-derived from the operation’s
summary field — MCP clients (Claude Desktop, Cursor, ChatGPT Apps)
render this in their tool pickers as a human-readable display name
in place of the sanitized tool slug. An explicit
x-mcp-annotations.title in the spec overrides the summary-derived
default; if neither is present, the field is omitted (clients fall
back to the tool name).
Each tool entry also carries _meta.spall.tags with the OpenAPI tag
list — useful for clients that surface tags in their UI.
Debugging
Pass --spall-verbose to spall mcp <api> (any transport) to dump
server-lifecycle and per-call diagnostics to stderr. Stdout
remains pure JSON-RPC; the verbose stream never crosses the protocol
channel, so it’s safe to enable while clients are connected.
Each event is one stderr line prefixed with [spall-mcp]:
[spall-mcp] kind=startup api=petstore transport=stdio tools=42 profiles=admin,readonly
[spall-mcp] kind=tools/call tool=getpetbyid profile=<default> method=GET url=/pets/{petId}
[spall-mcp] kind=http-request origin=https://app.example.com allowlist=https://app.example.com headers={...}
Profile names that appear in the startup line are the set spall
validated against your config. A profile only appears on a
tools/call line when a request actually triggered it — profile
resolution is lazy, so profiles you never invoke stay un-resolved
(and therefore can’t leak via expose_secret).
What is redacted
-
HTTP request headers (case-insensitive name match):
Authorization→ rendered asBearer [REDACTED],Basic [REDACTED], or[REDACTED]for other schemes; the auth scheme is preserved so “wrong auth kind” is still debuggable.Cookie→[REDACTED].Proxy-Authorization→ same asAuthorization.
The list is hardcoded in
spall-cli/src/mcp/verbose.rs::REDACTED_HEADER_NAMESand a unit-test drift guard asserts every entry actually triggers a redaction.
What is NOT redacted in v1
This is the honest scope statement — do not assume the verbose stream is safe to share verbatim:
- URL query parameters. The
tools/callline emits the OpenAPIpath_template(e.g./pets/{petId}), not the rendered URL with query string. Path-segment values (the{petId}substitution) are not in the verbose log because the actual rendering happens downstream of the MCP dispatcher. A future version may render + redact the URL with?api_key=[REDACTED]semantics. - Request bodies of the upstream API call.
- Response bodies and response headers of the upstream API call.
- Custom organization-specific header names outside the
hardcoded list above. If your spec uses
X-Foo-Tokenor similar for a credential, do not enable--spall-verbosein environments where stderr is captured to durable storage. - Browser CORS preflight rejections never reach the per-request log; only POST requests that pass the CORS layer are visible.
If you need to share a verbose dump, pipe through
--spall-verbose 2>&1 | tee debug.log and review debug.log
manually before sharing. The redactor closes the most common leakage
path (Authorization headers) but is not exhaustive.
--spall-verbose is also wired on the request-execution path (when
you run spall <api> <op>) for header-trace debugging; the two uses
of the flag are independent and may be combined.
Limitations
- Tools only. No MCP
resourcesorpromptssurfaces in v1. - Single request/response per tool. The HTTP transport’s SSE plumbing (content-negotiated POST + keep-alive GET channel) is in place, but every tool is one buffered round-trip — there is no long-running tool source emitting progress frames yet (#48) and no server-push source on the GET channel (#47).
oneOf/anyOf/allOfare flattened. Spall’s resolver collapses schema composition on load, so each tool’sinputSchemareflects a single resolved branch. If your spec relies heavily on polymorphism, the tool input shape may be coarser than the spec suggests.- Recursive schemas collapse. Schemas that hit the
$refcycle / depth guard emit{ "description": "cyclic schema omitted" }in place; clients see a permissive empty schema.
Running over HTTP
--spall-transport http switches the server from line-delimited
JSON-RPC over stdio to Streamable HTTP per MCP spec 2025-06-18
§HTTP. The wire shape:
- One POST endpoint at
/(the bind root). Body is one JSON-RPC 2.0 frame. A request (a frame with anid) content-negotiates its reply on theAcceptheader:- Default (
Accept: application/json, or noAcceptheader): one JSON-RPC reply object asapplication/json. The server MAY always answer JSON, per the spec’s “Sending Messages” clause 5, so this is the shape every client gets unless it opts into streaming. Accept: text/event-stream: the reply is atext/event-streambody carrying onedata:event per yielded frame, then the stream closes. spall v1 tools are all single request/response (one reply frame), so the SSE body normally carries a singledata:event; multi-frame streaming awaits a long-running tool source (see #48).- A notification or response frame carries no reply, so the server
answers
202 Acceptedwith an empty body, per the spec’s “Sending Messages” rule.
- Default (
- One GET endpoint at
/for the server→client SSE channel. With a validMcp-Session-IdandAccept: text/event-stream, the server opens a keep-alive-onlytext/event-stream— the conformant “I offer a stream but have nothing to push yet” shape. spall v1 has no server-push source, so the stream emits only keep-alive comment pings; per-session push subscription state is tracked in #47. The Origin and session-id gates apply identically to POST and DELETE. Mcp-Session-Idheader is issued oninitializeand required on every subsequent request. Sessions live for the process lifetime; restarting the server invalidates all existing sessions.- Session termination: a client ends its session with
DELETE /carrying itsMcp-Session-Id. The Origin gate applies identically and rejects (403) before the session-id is read. A valid header returns200 OKwith no body; the operation is idempotent — a secondDELETEfor the same (now-absent) id still returns200 OK, since the session no longer exists either way. A missing or emptyMcp-Session-Idheader is a malformed request and returns400 Bad Request. MCP-Protocol-Versionheader is validated on every post-initializerequest. If the header is absent, the server assumes2025-03-26(the spec’s backward-compatibility default) and proceeds. If it is present but unsupported, the request gets400 Bad Request. The supported set is2025-06-18(advertised),2025-03-26(assumed default), and2025-11-25.initializeis exempt, since the client has not yet learned a version to send.- Streaming (
text/event-stream) is wired on both the POST reply (viaAcceptcontent negotiation) and the GET channel, as described above. spall’s v1 tools are all single request/response, so an SSE-accepting POST carries a singledata:event and the GET channel is keep-alive-only — the enabling shape is in place, but there is no multi-frame / server-push source yet (#47, #48).
# Localhost by default (MCP spec recommendation; mitigates DNS rebinding).
spall mcp petstore --spall-transport http --spall-port 8765
# Bind on all interfaces — combine with a reverse proxy that adds auth + TLS.
spall mcp petstore --spall-transport http --spall-port 8765 --spall-bind 0.0.0.0
# Pass --spall-port 0 to let the kernel pick a free port. The bound
# port is logged to stderr:
spall mcp petstore --spall-transport http --spall-port 0
# [spall-mcp] listening on http://127.0.0.1:54321/
Origin allowlist (DNS rebinding mitigation)
The spec requires the server to validate the Origin header to block
DNS-rebinding attacks. spall’s policy:
-
Allowlist set (
--spall-allowed-origin <origin>, repeatable): only listed origins succeed; all others get403 Forbidden. The CORS preflight layer is configured against the same list so browsers see a coherent preflight rejection rather than a generic CORS error.spall mcp petstore --spall-transport http \ --spall-allowed-origin https://app.example.com \ --spall-allowed-origin https://staging.example.com -
Allowlist empty (default): non-browser callers (no Origin header — curl, the MCP test client) and localhost browsers (
http://localhost[:N],http://127.0.0.1[:N],http://[::1][:N], same withhttps) succeed. Browsers with a remote Origin get403. This closes the DNS-rebinding hole where an attacker- controlled DNS record atlocalhost.example.com → 127.0.0.1could otherwise drive a victim’s browser into the local server.
Request body size
Capped at 16 MiB. Larger requests get HTTP 413 Payload Too Large.
OpenAPI specs with very large multipart payloads should sit behind a
reverse proxy that handles streaming uploads, or run as stdio.
TLS, auth on the HTTP endpoint
Both are deliberately not in scope for the spall server itself. The
expected deployment is a reverse proxy (Caddy / Nginx / Cloudflare /
fly proxy / etc.) that terminates TLS and adds auth, with spall
listening on a private port behind it. This matches the
claude-desktop / chatgpt-apps deployment pattern and keeps spall’s
dep tree small.
Sizing your server
MCP clients impose practical limits on how many tools they surface from a single server. Claude Desktop in particular silently truncates near 100 tools (see modelcontextprotocol/discussions/537). Stripe / AWS / GitHub-class specs blow well past this in one server.
Spall surfaces this in three ways:
-
Startup warning. When the filtered tool count exceeds 100, the server emits a stderr warning naming the most populated tags so you can pick a filter:
spall mcp: WARNING 247 tools exceeds the ~100-tool cap most MCP clients ... spall mcp: top tags by population: users=42, orgs=38, repos=37, gists=21, billing=18 -
Discovery flag.
--spall-list-tagsdumps every tag in the filtered registry as TSV without starting the server, so you can shape your--spall-includelist ahead of time:$ spall mcp github --spall-list-tags tag count sample-op-id actions 48 actions/list-workflow-runs billing 12 billing/get-shared-storage ... -
Auto-truncation.
--spall-max-tools <N>deterministically caps the registry. The ordering rule is:- Bucket each operation by its first tag in spec order
(untagged operations land in
default). - Sort buckets alphabetically.
- Within each bucket, keep spec order.
- Take the first
N; ties on count are broken by spec order.
The selected subset is stable across runs on the same spec — useful for predictable CI behavior. An operation that’s truncated out has no way to come back without rerunning with a higher
Nor a different filter. - Bucket each operation by its first tag in spec order
(untagged operations land in
Troubleshooting
Claude Desktop only shows some of my tools
See Sizing your server. The startup warning is
your first signal; --spall-list-tags plus --spall-include or
--spall-max-tools are the levers.
“Server disconnected” / corrupted JSON-RPC stream
Stdio MCP requires that only JSON-RPC is written to stdout. Spall’s
server hot path uses eprintln! for diagnostics and never writes to
stdout outside of protocol replies. Sanity check:
echo '' | spall mcp <api>
The server should print its single-line stderr banner and exit on EOF with zero stdout output.
“Unknown argument”
Tool arguments are routed to the parameter location declared in the
spec. Pass each parameter by its spec name (not the --query /
--header flag used on the CLI). The reserved key body carries the
JSON request body when the operation declares one.
Shell Completions
Spall can generate completion scripts for bash, zsh, and fish. Because operations are loaded dynamically from specs, the completion scripts query spall itself at runtime to suggest API names and operation IDs.
Bash
spall completions bash > /etc/bash_completion.d/spall
# or user-local:
spall completions bash > ~/.local/share/bash-completion/completions/spall
Zsh
spall completions zsh > "${fpath[1]}/_spall"
Fish
spall completions fish > ~/.config/fish/completions/spall.fish
How It Works
The generated scripts are thin wrappers around spall’s __complete hidden subcommand:
spall __complete <api> <partial-word>
This loads the spec (or its cached index if offline) and prints matching operation IDs and parameter names. Completion is fast even for large APIs because it uses the lightweight SpecIndex cache.
Next Steps
Exit Codes
Spall returns structured exit codes so shell scripts and CI pipelines can branch on outcome without parsing stderr.
| Code | Meaning | When It Happens |
|---|---|---|
| 0 | Success | 2xx HTTP response |
| 1 | Usage error | Missing required argument, unknown API/operation, bad flag, config parse failure |
| 2 | Network error | DNS failure, TCP timeout, TLS error, proxy failure, stale cache with no fallback |
| 3 | Spec error | YAML/JSON parse failure, dangling $ref, invalid OpenAPI structure, cache corruption that cannot be rebuilt |
| 4 | HTTP 4xx | Client error responses (400, 401, 403, 404, etc.) |
| 5 | HTTP 5xx | Server error responses (500, 502, 503, etc.) |
| 10 | Validation failed | Preflight parameter or body schema validation failed |
Scripting Examples
Retry on network failure
spall petstore get-pet-by-id 1 || [ $? -eq 2 ] && sleep 5 && spall petstore get-pet-by-id 1
Skip downstream steps on 4xx
spall github get-repo rustpunk/spall || {
code=$?
if [ "$code" -eq 4 ]; then
echo "Repo not found, skipping build..."
exit 0
fi
exit "$code"
}
Fail CI on validation errors
spall internal create-order --data @order.json || {
code=$?
if [ "$code" -eq 10 ]; then
echo "Validation failed — check your payload."
fi
exit "$code"
}