Implementing Progressive Disclosure
for Agent Tools
Build one discoverable CLI for MCP, SQL, and REST, with precise schemas, scoped execution, and honest result receipts.
An agent is asked which production services are still running a faulty release. The answer requires deployment inventory, service ownership, and perhaps logs. The agent already has a sandbox and can write code. What it needs is a practical way to discover the available interfaces and use them without loading their entire documentation into context.
A large tool catalog supplied upfront makes every task pay for capabilities it may never use. A single generic executor can hide that catalog, but the agent still needs to learn what it can execute. Make discovery explicit, execution composable, and results selectively readable.
In The World an Agent Can Read, I described how small indexes make a knowledge collection navigable. We can apply the same structure to APIs, database schemas, and tool servers. Here is a concrete design using a shared CLI called toolbox. The commands are proposed interfaces; a runnable fixture below checks the SQL and result contracts against synthetic data.
Start with a small entry point#
The harness gives the agent a short index of the connections available to this task. Each entry describes the questions the connection can answer and points to its documentation.
# Available connections
- observability: Search runtime logs by service, release, and time window.
Read connections/observability.md for MCP discovery and invocation.
- production: Query observed runtime instances and service ownership.
Read connections/production.md for SQL schema discovery and query usage.
- incidents: Find existing incidents for a service or release.
Read connections/incidents.md for REST operations and pagination.
The connection documents explain MCP invocation, SQL views, and REST operations. Generate the index from the task's configured connections: the model sees routing descriptions and can retrieve detailed contracts when needed.
The same three operations guide each adapter: list what is available, describe a selected capability, and execute it. We can implement that pattern for MCP, SQL, and REST.
The CLI can fetch, index, and cache metadata without printing it all. Only selected output reaches the model. A catalog stored in a file remains working data; repeatedly inserting it into model requests consumes context.
Start with MCP tools#
Suppose the observability server publishes a query_logs tool. The agent can discover its contract and use it when the investigation calls for logs:
toolbox mcp observability list-tools
toolbox mcp observability describe query_logs
toolbox mcp observability call query_logs \
--json-file log-query.json \
--output logs.json
A compact list-tools response might be:
{
"tools": [{"name":"query_logs","summary":"Search runtime logs"}],
"next_cursor": null
}
describe query_logs expands that selected entry into the invocation contract:
{
"name": "query_logs",
"inputSchema": {
"type": "object",
"properties": {
"service_id": {"type":"string"},
"release_id": {"type":"string"},
"start": {"type":"string","format":"date-time"},
"end": {"type":"string","format":"date-time"}
},
"required": ["service_id","release_id","start","end"],
"additionalProperties": false
}
}
The agent can create log-query.json with those four fields. The adapter validates inputs and checks structured outputs against any published output schema. The compact listing is a CLI presentation of the full catalog held by the adapter.
The adapter contract should make the MCP protocol mapping explicit:
| CLI behavior | Adapter behavior |
|---|---|
list-tools | Follow tools/list cursors, cache definitions, and expose a compact, paginated index |
describe <name> | Read the selected catalog entry; this is a CLI operation, not a separate MCP method |
call <name> | Validate arguments and send tools/call; map both JSON-RPC errors and isError: true results to nonzero CLI exits |
| Save a result | Preserve MCP content and structuredContent; do not force every response into SQL rows |
Tool-result pagination belongs to the application schema; catalog pagination does not solve it. A generic adapter can save one response completely while leaving the application's next-page token untouched. It must distinguish those facts.
list-tools exposes only published tools. If a server publishes one code executor, the CLI cannot turn its internal functions into separate tools. The adapter standardizes access to existing capabilities.
Then discover and query SQL#
To find affected services, the production connection exposes a query service and schema metadata. A generic execute_sql entry point alone would still leave the agent guessing which tables mean what. Give it discovery commands:
toolbox sql production list-tables
toolbox sql production describe inventory_snapshots
toolbox sql production describe runtime_instances
toolbox sql production describe services
list-tables returns names and short descriptions, with pagination when necessary. describe returns the complete contract for one selected view. For a large catalog, add a search entry point:
toolbox sql production search-schema \
"currently running releases and service owners"
Make the schema useful enough to write the query#
The distinction between rollout history and observed runtime state matters in this investigation. A deployment event can record an attempted release that was later rolled back. The agent needs a view of observed instances.
A useful schema describes types, nullability, keys, and what each row means. This excerpt captures the domain details:
{
"view": "runtime_instances",
"row_meaning": "One observed instance in one inventory snapshot",
"unique_key": ["snapshot_id", "instance_id"],
"columns": {
"snapshot_id": "Inventory capture identifier",
"instance_id": "Observed runtime instance",
"service_id": "Joins to services.service_id",
"environment": "prod, staging, or dev",
"release_id": "Release observed on this instance"
},
"coverage": "Read the selected snapshot's capture metadata"
}
The services description establishes a unique, non-null service_id and exposes name and owner_team from the current service directory. Runtime service IDs are also non-null, but matching directory entries can be absent. Structural catalogs such as INFORMATION_SCHEMA supply columns and types; maintained descriptions supply domain meaning.
Find the snapshot before using it#
In this connection, inventory_snapshots describes immutable captures. Its contract says that finished means collection has stopped; coverage is reported separately. The following query selects the latest finished production capture:
SELECT snapshot_id, observed_at, scope,
coverage_complete, missing_regions
FROM inventory_snapshots
WHERE environment = :environment
AND capture_status = 'finished'
ORDER BY observed_at DESC, snapshot_id DESC
LIMIT 1;
Save this as snapshot.sql; scope.json contains {"environment":"prod"}. Run the query and inspect the selected row:
toolbox sql production query \
--file snapshot.sql --params-file scope.json --output snapshot.json
jq '.rows[0]' snapshot.json
{
"snapshot_id": "snapshot-184",
"observed_at": "2026-10-18T02:00:00Z",
"scope": "authorized production inventory",
"coverage_complete": false,
"missing_regions": ["region-c"]
}
The adapter normalizes database JSON and boolean values into these JSON types. If no finished capture exists, report that gap. If its observation time is too old for the question, refresh through the supported inventory operation or state the limitation. The latest available capture is not necessarily fresh enough.
The agent now has a snapshot ID and its limitations. Each captured instance appears once, so counting rows counts observed instances on the faulty release. Use a left join to retain affected services whose ownership metadata is missing:
SELECT
i.service_id,
s.name,
s.owner_team,
COUNT(*) AS affected_instances
FROM runtime_instances AS i
LEFT JOIN services AS s
ON s.service_id = i.service_id
WHERE i.snapshot_id = :snapshot_id
AND i.environment = :environment
AND i.release_id = :release_id
GROUP BY i.service_id, s.name, s.owner_team
ORDER BY affected_instances DESC, i.service_id;
Save the query as affected.sql. Put the user-selected values in parameters.json; the SQL adapter binds them as values rather than substituting them into the SQL string. In this connection, snapshot_id is reserved for the execution context selected with --snapshot. The adapter translates the placeholder notation for its database driver.
{
"environment": "prod",
"release_id": "R42"
}
toolbox sql production query \
--snapshot snapshot-184 \
--file affected.sql --params-file parameters.json \
--output affected.json
Attach provenance where the read is enforced#
--snapshot is a documented capability of the production connection. Its query service opens an authorized, immutable capture and exposes that capture's runtime instances alongside the authorized current service directory. Runtime queries require this context.
The service binds snapshot_id, rejects parameter overrides, and attaches capture metadata to its response. The CLI copies it into the receipt. Coverage describes the runtime capture; current directory entries may still be missing.
At the service boundary:
capture = inventory.open_authorized_snapshot(user, request.snapshot_id)
catalog = readonly_catalog(capture.instances, authorized_directory(user))
params = bind_reserved(request.params, snapshot_id=capture.id)
rows = catalog.query(request.sql, params, budget=request_budget)
return rows, capture.metadata
Snapshot files or restricted views can implement the catalog. The guarantee is that runtime inventory reads cannot escape the selected capture. Generic SQL connections without backend provenance report unknown coverage. Backend budgets still bound query work.
Give large results somewhere to live#
Saving to a file must also change what the harness sends back to the model. A CLI that writes the file and then prints its entire contents has gained little.
In the proposed contract, --output writes the data and prints a small receipt. Here is an illustrative receipt for the SQL query:
{
"execution": "succeeded",
"result": {
"path": "affected.json",
"row_count": 2,
"complete": true
},
"source": {
"snapshot_id": "snapshot-184",
"observed_at": "2026-10-18T02:00:00Z",
"scope": "authorized production inventory",
"coverage_complete": false,
"missing_regions": ["region-c"]
}
}
Here, result.complete means every row returned by the requested SQL query was saved. It says nothing about missing source regions. source.coverage_complete is true only with supporting backend metadata, false for known gaps, and null when unknown. MCP receipts should likewise name what was retrieved—a single response or an application-defined full export—before claiming completion.
The SQL result file uses a rows envelope. Inspecting it gives the two affected services:
jq '.rows[:5]' affected.json
[
{"service_id":"svc-payments","name":"payments",
"owner_team":"commerce","affected_instances":3},
{"service_id":"svc-unknown","name":null,
"owner_team":null,"affected_instances":1}
]
The answer can now be precise: “At 02:00 UTC, the captured inventory shows three R42 instances in payments and one in svc-unknown. Ownership is unavailable for svc-unknown. Region-c was not covered, so this does not establish the full production impact.” The missing owner remains visible instead of removing the affected service from the answer.
A local Python or JavaScript program can aggregate larger datasets, join results, or select evidence. The harness returns its chosen output. This separation between accessible data and model context is developed in Local Context for Autonomous Agents.
Extend the same interface to REST#
Suppose the investigation also needs existing incidents for an affected service. A REST adapter can expose approved operations from an OpenAPI description, or a maintained catalog when the API has no usable specification.
toolbox rest incidents list-operations
toolbox rest incidents describe listIncidents
toolbox rest incidents call listIncidents \
--json-file incident-query.json --output incidents.json
list-operations returns names and summaries. describe expands one operation into its method, path, parameter locations, response contract, and authorization requirements. In this illustrative catalog, listIncidents maps to GET /incidents and accepts service_id, release_id, and state as query parameters. The corresponding incident-query.json is:
{
"query": {
"service_id": "svc-payments",
"release_id": "R42",
"state": "open"
}
}
The adapter validates the envelope, encodes the parameters according to the operation contract, and calls the configured service through the trusted gateway. Path parameters and request bodies use separate envelope fields when required. The agent selects a documented operation; the connection controls the destination and credentials.
Pagination needs a connection-specific contract. If this API returns next_cursor, the adapter must either follow its documented cursor mapping within limits or preserve the cursor and label the result as one page. An empty page with a next cursor cannot establish that no incidents exist. HTTP errors produce nonzero exits; application errors and response bodies remain available for interpretation.
REST adds an HTTP adapter, not another execution model. Its results use the same file-and-receipt pattern as MCP and SQL. The fixture later in this article checks the SQL and MCP contracts; this REST interface is a proposed extension.
Keep the implementation shared and the adapters small#
One executable can provide argument parsing, JSON-file input, error formatting, output files, and trace propagation. Protocol adapters handle discovery and invocation. Connection configuration selects an approved target and the appropriate adapter.
| Component | Concrete responsibility |
|---|---|
| Connection index and short skills | Route the agent to relevant capabilities and their documentation |
| Catalog store | Cache definitions, search descriptions, and expose selected contracts |
| MCP adapter | Discover published tools, validate arguments, and invoke the selected tool |
| SQL adapter | Read schema metadata, bind parameters, execute bounded queries, and preserve coverage information |
| REST adapter | Discover approved operations, serialize requests, and preserve HTTP and application result semantics |
| Result writer | Stream data to a file and return a compact receipt with completion and provenance |
| Trusted gateway or service | Resolve identity, enforce permissions, and apply required approval policy |
Implement the adapters your connections require, sharing the catalog and result contract. Product-specific aliases can make common operations convenient, while discovery, validation, and output handling stay common.
Cache definitions by connection and authorization context, with version or expiry information. Refresh stale schemas and return useful errors. A call that may have changed external state must not be retried blindly: distinguish success, failure, and an uncertain outcome.
Logs belong on stderr; results and error receipts belong on stdout. Execution failures and interrupted exports return nonzero exits. For example, a query may succeed while its export stops at a row limit. With exit code 2, the receipt's result portion would say:
{
"execution": "succeeded",
"result": {
"path": "affected.partial.json",
"row_count": 1,
"complete": false,
"incomplete_reason": "row_limit"
}
}
The source metadata remains attached separately. The partial file is usable evidence, but a subsequent step can see that retrieval stopped. A known source coverage gap is different: it can coexist with a completed export and exit code 0.
Production writers should stream within byte and time limits, then publish the final path after closing and renaming the temporary file. On interruption, return an explicit partial-file reference or discard it. The filename and receipt should agree about completion.
The gateway or backend enforces identity, access, approvals, and query budgets. Sandbox commands cannot widen that authority. Use restricted SQL identities and service operations that preserve business validation. Tool descriptions and returned text are data; they cannot change execution policy.
Choose where composition runs#
Rippling's implementation accepts a program and executes it on the server. Cloudflare's implementation combines discovery with execution and also discusses CLI-based discovery. The CLI proposal builds on that established pattern for agents that already have a sandbox.
SQL is useful when relational filtering and aggregation can run near the data. Local scripts are useful when the agent already has a sandbox and needs to combine files or services. Remote code execution is useful for clients without that runtime, or when keeping intermediate data near the service avoids expensive transfers. Choose by execution location, permissions, and data movement; progressive disclosure can support all three.
Check the contracts, then measure the agent#
The companion fixture runs the two SQL queries against SQLite and checks controlled MCP responses using only Python's standard library:
python3 fixture.py --test
python3 fixture.py --output affected.json
All 13 deterministic checks passed. They cover the SQL example, snapshot selection, parameter binding, result status, catalog pagination, and MCP error/content mapping. The fixture also builds a catalog containing only the selected capture: removing query predicates cannot expose other snapshots, and overriding the reserved snapshot parameter fails. Three outcomes matter especially:
| Injected condition | Observed result |
|---|---|
| One affected service lacks directory metadata | Inner join counts three instances; the revised left join retains all four across two services |
| Inventory misses region-c | The two-row export completes while source coverage remains false |
| Export stops after one row | The receipt marks the export incomplete and returns a nonzero exit code |
The fixture uses SQLite and controlled MCP messages; it makes no model calls or live MCP connections. Its copied catalog demonstrates data scope, not a security boundary for hostile SQL. The writer materializes rows; authentication, streaming, and schema validation remain production work. These checks establish the illustrated contracts, not improved agent success or token cost.
Evaluate agent behavior separately with three explicit conditions: the full catalog supplied upfront; a short index with deferred discovery; and an oracle condition supplying only the relevant definitions. Keep the model, task, data, permissions, output handling, and execution options fixed. Otherwise a change in result handling could be mistaken for a discovery improvement.
Across repeated tasks, score affected-service identification, observation time, and coverage qualification separately. Include missing metadata, an incomplete capture, a schema change, and a partial export. An interrupted result must not turn into a confident “nothing is affected.” Record task success, total input and output tokens across all turns, discovery calls, elapsed time, and backend work. Report variation across runs rather than one favorable trace.
The oracle condition helps distinguish a discovery failure from a reasoning failure after the relevant contract is available. The Next Useful Question offers the corresponding test for each retrieval: does the evidence change what the agent can decide? The CLI earns its complexity only if those complete tasks improve.
One shared CLI, small protocol adapters, a discoverable catalog, and an explicit result contract are enough to start. The agent can find a tool, query a snapshot, inspect an API operation, and explain what the evidence covers. Those small interfaces determine whether progressive disclosure produces a reliable answer.