Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

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

  1. Phase 1 — spall scanned your config registry and matched petstore as a registered API name.
  2. Phase 2 — spall loaded the cached spec, resolved all $refs, merged parameters, and built a dynamic clap command tree.
  3. Execution — spall validated your inputs against the schema, built the HTTP request, sent it, and formatted the response.

Next Steps

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 inInternal IDUser-facing
pathpath-idpositional argument
queryquery-id--id
headerheader-id--header-id
cookiecookie-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:

  1. --spall-auth CLI override
  2. Per-API config [auth] section via token_url / password_url (hasp), token_env / password_env, or inline token (warned)
  3. Legacy global SPALL_<API>_TOKEN env var (Wave 1–2 compat)
  4. 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:

CodeMeaning
0Success (2xx response)
1CLI usage error
2Network / connection error
3Spec loading / parsing error
4HTTP 4xx response
5HTTP 5xx response
10Request 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:

FilenameRegistered name
petstore.jsonpetstore
my-internal-api.yamlmy-internal-api
v2_billing.ymlv2-billing

Priority (highest → lowest):

  1. apis/*.toml files
  2. [[api]] inline entries in config.toml
  3. spec_dirs scanned 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 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:

  1. --spall-server CLI flag
  2. Per-API config base_url
  3. Operation-level servers from the spec
  4. Spec-level servers
  5. 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

ContextFormat
TTY (interactive terminal)Pretty-printed JSON with syntax highlighting
Pipe / file redirectRaw 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. The auth.token_url field has a different meaning for the OAuth2 kind (the IDP token endpoint) than for Bearer/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:

FieldAuth KindsExample URL
token_urlBearer, ApiKeyenv://MY_TOKEN, file:~/secrets/api.key, keyring://spall/api-token
password_urlBasicenv://ALICE_PASSWORD, file:/run/secrets/password, keyring://spall/alice-pass
client_secret_urlOAuth2 (confidential clients)env://CLIENT_SECRET

For kind = "OAuth2", token_url is the IDP token endpoint (e.g. https://idp.example/oauth/token), not a hasp URL. See “OAuth2 (Authorization Code + PKCE)” above.

URL Schemes

SchemeFormatUse Case
env://env://VAR_NAMECI, Docker, local overrides
file://file:///absolute/path or file:~/relativeKubernetes secret mounts, dotfiles
keyring://keyring://service/entrymacOS 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-debug wire 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 next link 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-paginate cannot 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

FlagShortDescription
--spall-output-OOutput format: json/pretty, raw, yaml, table, csv, or @file
--spall-download-oSave response body to a file
--spall-verbose-vPrint request/response headers to stderr
--spall-debugWire-level debug logging (redacts secrets)
--spall-timeInclude request/response timing in verbose output
--filterJMESPath filter expression for JSON responses

Network Control

FlagShortDescription
--spall-server-sOverride base URL for this request
--spall-timeout-tTimeout in seconds (default: 30)
--spall-retryRetry count for failed requests (default: 1, max: 3)
--spall-redirect-LFollow HTTP 3xx redirects (default: off)
--spall-max-redirectsMaximum redirects (default: 10)
--spall-insecureSkip TLS certificate verification
--spall-ca-certPath to custom CA certificate (PEM or DER)
--spall-certPath to client certificate PEM (mTLS); requires --spall-key
--spall-keyPath to client private key PEM (mTLS); requires --spall-cert
--spall-proxyHTTP/SOCKS proxy URL
--spall-no-proxyDisable proxy for this request

Request Modification

FlagShortDescription
--spall-header-HInject a non-sensitive header (repeatable)
--spall-auth-APass-through auth token/header
--spall-content-type-cOverride request content type

Execution Control

FlagShortDescription
--spall-dry-runPrint curl equivalent without executing
--spall-previewShow resolved URL, headers, and body without sending
--spall-paginateAuto-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-waitMaximum seconds to honor a Retry-After header before giving up (default: 60)
--spall-repeatReplay the most recent request from history
--spall-chainJMESPath chain expression for multi-stage requests
--profileActive 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

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

FieldTypeDescription
sourcestringSpec file path or URL (required)
base_urlstringOverride the spec’s server URL
proxystringHTTP/SOCKS proxy URL for this API
authtableAuth configuration (tokens, env vars, hasp secret URLs)
headerstableHeaders added to every request
profilestableNamed 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 lightweight SpecIndex for degraded --help when 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_url replaces the base config value entirely.
  • auth replaces the base config auth entirely.
  • proxy replaces the base config proxy value entirely.
  • headers are 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):

  1. --spall-no-proxy — disables proxying entirely for the request.
  2. --spall-proxy <url> — CLI override.
  3. Per-API proxy in ~/.config/spall/apis/{name}.toml.
  4. Profile proxy in the active per-API profile.
  5. Global default proxy in ~/.config/spall/config.toml.
  6. Environment variablesHTTPS_PROXY, then HTTP_PROXY, then ALL_PROXY.
  7. 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:

VariableDescription
HTTPS_PROXYPreferred for HTTPS destinations
HTTP_PROXYFallback
ALL_PROXYFallback for any protocol
NO_PROXYComma-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 — matches example.com and *.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 reqwest and is not logged by spall. Use --spall-debug with care; reqwest may still include the URL in wire logs.

Next Steps

CLI Reference

Top-Level Commands

spall [OPTIONS] <COMMAND>
CommandDescription
spall api ...Manage registered APIs
spall auth ...Authentication commands
spall history ...Request/response history
spall completions ...Generate shell completion scripts
spall replStart the interactive REPL shell
spall <api> <operation> [args]Execute an API operation

spall api

SubcommandDescription
spall api add <name> <source>Register a new API
spall api listList registered APIs
spall api remove <name>Unregister an API
spall api refresh [<name>]Refresh cached remote spec
spall api refresh --allRefresh all cached remote specs
spall api discover <url>Discover and register an API via RFC 8631

spall auth

SubcommandDescription
spall auth status <api>Show auth status for an API
spall auth login <api>Initiate OAuth2 PKCE login (stub)

spall history

SubcommandDescription
spall history listList recent requests
spall history show <id>Show full request details
spall history searchSearch history with filters
spall history clearErase 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

CommandDescription
helpShow available commands
historyList the last 20 recorded requests
quit / exitLeave 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 rustyline for 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-bind CLI 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.

ExpressionResolves to
$inputs.emailThe --input email=... value
$workflow.inputs.regionAlias for $inputs.region
$steps.<id>.outputs.<name>A named output from an earlier step
$steps.<id>.response.body#/path/to/fieldRFC 6901 JSON Pointer into the step body
$steps.<id>.response.header.X-Request-IdResponse header (RFC 9110 case-insensitive)
$steps.<id>.response.statusCodeInteger status code
$response.body#/foo (only in outputs/criteria)Current step’s response body
$response.header.<Name>Current step’s response headers
$response.statusCodeCurrent 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:

  1. Explicit override: if the source description has x-spall-api: <api-name>, bind to that spall API.
  2. Name match: if <source.name> matches a registered spall api, bind to it.
  3. 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 operationId nor operationPath nor workflowId. Exit code 10.
  • Warnings — the doc uses operationPath (v2), workflowId (v2, nested workflows), or a non-simple successCriteria.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 (in onSuccess or successActions) → exit 0 with workflow outputs.
  • Failure-side type:end (in onFailure or failureActions) → 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:

  • onFailure field 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:

FieldTypeMeaning
stepIdstringThe step’s stepId from the .arazzo.yaml.
statusintegerHTTP status of the step’s response, or 0 if no HTTP call ran (criteria-only fail).
dryRunbooleantrue when the step body was skipped due to --dry-run.
outputsobjectValues from the step’s outputs: expressions. Empty when the step failed.
failedViastring | absentOnly 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

Featurev1Tracking
failureActions / successActions (workflow + step level)Implemented (this release)
onSuccess / onFailure actionsImplemented (this release)
$components.successActions / $components.failureActions refsImplemented (this release)
workflowId (nested)Errors at runtimeissue #5
replay actionNot implementedissue #5
operationPathErrors at runtimeissue #5
successCriteria.type: regex / jsonpathSkipped with a warningissue #5
Action criteria.type: regex / jsonpathErrors at dispatchissue #5
--spall-bind <source>=<api> CLI overrideUse x-spall-api extensionissue #5
Inputs JSON Schema validationNone — values are opaque stringsissue #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-transport selects the wire protocol:
    • stdio (default) for Claude Desktop / config-launched servers.
    • http for 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 to N tools when the spec exceeds the cap. See Sizing your server for the ordering rule.
  • --spall-list-tags loads the spec, prints a tag\tcount\tsample-op-id TSV to stdout, and exits without starting the server. Useful for crafting an --spall-include filter.
  • Operations with no tags belong to a synthetic tag named default — 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:

operationIdtool name
getPetByIdgetpetbyid
create usercreate-user
Foo::Barfoo-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):

MethodreadOnlyHintdestructiveHintidempotentHint
GET / HEAD / OPTIONS / TRACEtruefalsetrue
PUT / DELETEfalsetruetrue
PATCHfalsetruefalse
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 as Bearer [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 as Authorization.

    The list is hardcoded in spall-cli/src/mcp/verbose.rs::REDACTED_HEADER_NAMES and 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/call line emits the OpenAPI path_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-Token or similar for a credential, do not enable --spall-verbose in 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 resources or prompts surfaces 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 / allOf are flattened. Spall’s resolver collapses schema composition on load, so each tool’s inputSchema reflects 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 $ref cycle / 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 an id) content-negotiates its reply on the Accept header:
    • Default (Accept: application/json, or no Accept header): one JSON-RPC reply object as application/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 a text/event-stream body carrying one data: 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 single data: 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 Accepted with an empty body, per the spec’s “Sending Messages” rule.
  • One GET endpoint at / for the server→client SSE channel. With a valid Mcp-Session-Id and Accept: text/event-stream, the server opens a keep-alive-only text/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-Id header is issued on initialize and 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 its Mcp-Session-Id. The Origin gate applies identically and rejects (403) before the session-id is read. A valid header returns 200 OK with no body; the operation is idempotent — a second DELETE for the same (now-absent) id still returns 200 OK, since the session no longer exists either way. A missing or empty Mcp-Session-Id header is a malformed request and returns 400 Bad Request.
  • MCP-Protocol-Version header is validated on every post-initialize request. If the header is absent, the server assumes 2025-03-26 (the spec’s backward-compatibility default) and proceeds. If it is present but unsupported, the request gets 400 Bad Request. The supported set is 2025-06-18 (advertised), 2025-03-26 (assumed default), and 2025-11-25. initialize is exempt, since the client has not yet learned a version to send.
  • Streaming (text/event-stream) is wired on both the POST reply (via Accept content negotiation) and the GET channel, as described above. spall’s v1 tools are all single request/response, so an SSE-accepting POST carries a single data: 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 get 403 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 with https) succeed. Browsers with a remote Origin get 403. This closes the DNS-rebinding hole where an attacker- controlled DNS record at localhost.example.com → 127.0.0.1 could 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:

  1. 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
    
  2. Discovery flag. --spall-list-tags dumps every tag in the filtered registry as TSV without starting the server, so you can shape your --spall-include list 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
    ...
    
  3. 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 N or a different filter.

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.

CodeMeaningWhen It Happens
0Success2xx HTTP response
1Usage errorMissing required argument, unknown API/operation, bad flag, config parse failure
2Network errorDNS failure, TCP timeout, TLS error, proxy failure, stale cache with no fallback
3Spec errorYAML/JSON parse failure, dangling $ref, invalid OpenAPI structure, cache corruption that cannot be rebuilt
4HTTP 4xxClient error responses (400, 401, 403, 404, etc.)
5HTTP 5xxServer error responses (500, 502, 503, etc.)
10Validation failedPreflight 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"
}

Next Steps