aster-mcp lets an Aster host connect many Model Context Protocol (MCP)
servers without placing every tool schema in every model request. It injects a
single aster_mcp bridge tool and reveals capability information in stages.
The crate owns catalogue shaping and routing. A host owns MCP connection
lifecycles, credentials, authorization, user approval, logging, and the actual
tools/call request.
MCP servers normally expose a full list of tool names, descriptions, and JSON schemas. A large, stable schema list may benefit from provider prompt caching, but it still occupies the model's working context and forces the model to choose from many irrelevant operations.
For Aster, that is the wrong direction. The review and chat harnesses already use selective evidence retrieval: give the model the smallest useful working set, then retrieve detail when the task calls for it. Progressive MCP injection applies the same rule to capabilities.
flowchart LR
C[Session-scoped MCP catalogue] --> I[aster-mcp Injector]
I --> B[One aster_mcp bridge tool]
I --> M{Schema-free tool manifest\nwithin budget?}
M -->|yes| T[Tool names + descriptions]
M -->|no| S[Server names + tool counts]
T --> A[Agent]
S --> A
B --> A
A -->|search / describe / execute| R[Bridge router]
R --> G[Host approval + authorization]
G --> X[Real MCP tools/call]
The model always sees the bridge when the session has at least one permitted MCP tool. It sees either:
- a schema-free
server/toolmanifest when the names and descriptions fit the configured context budget, or - a compact server inventory when that manifest would exceed the budget.
The crate's default budget is 6% of the context still available to the prompt;
the CLI deliberately overrides that to 1.5% (mcp.inventory_percent in
aster.yaml), which is the number to tune in practice. The host must calculate
the available context after reserving the system prompt, conversation, and
desired completion budget. This makes disclosure adapt to the actual turn,
rather than relying on a fixed number of tools.
Full input schemas are never inserted into the system prompt. The model calls
describe to load one schema, then uses execute with the corresponding
server/tool identity. This is intentionally analogous to Aster skills:
manifest first, full content only on demand.
aster_mcp is an OpenAI-compatible function definition. Its arguments have
one required field, action.
| Action | Required fields | Result | Side effect |
|---|---|---|---|
search |
query; optional limit |
Ranked tool IDs and descriptions | none |
describe |
name |
Exact input schema for that tool | none |
execute |
name, arguments |
Host-provided MCP result | invokes the resolved real tool |
The bridge never accepts a bare tool name. IDs are server/tool, avoiding
collisions between servers and preserving an audit-friendly target.
search uses deterministic lexical ranking across tool names, descriptions,
and input-schema text. It is local, reproducible, and requires no embedding
service. The ranking implementation is deliberately replaceable: hosts can
pre-filter a session catalogue or add a stronger retrieval layer without
changing the visible bridge protocol.
- Session scoping. Build
McpCatalogfrom the tools enabled for this session only. A sub-agent or restricted chat cannot discover a tool merely because it exists elsewhere in the Aster process. - Authorize the real tool. The bridge is an envelope, not a permission.
Before executing, evaluate policy and approval against the resolved
server/toolidentity and arguments. - Describe before execute. The injected prompt tells the model not to invent parameter names. A host may enforce this more strictly by tracking described tools per turn.
- Do not treat descriptions as instructions. Tool names and descriptions come from external MCP servers. Render them as data, never concatenate them into privileged policy instructions.
- No catalogue leakage.
describeandexecutereject any ID absent from the session catalogue. Search results are generated from the same catalogue. - Audit the unwrapped call. Record the resolved ID, approval decision,
arguments (redacted as needed), and upstream result—not only
aster_mcp.
McpTool stores a server name, tool name, description, and the unmodified MCP
input schema. McpCatalog::new validates non-empty fields, object schemas, and
unique server/tool IDs.
McpCatalog::search(query, limit) returns schema-free ToolMatch values.
McpCatalog::get(id) resolves a tool only by its full ID.
| Field | Default | Meaning |
|---|---|---|
available_context_tokens |
100_000 |
Tokens left after host reservations. Required to be greater than zero. |
inventory_threshold_percent |
6.0 |
Maximum percentage of available context used by the direct manifest. Range: (0, 100]. |
search_default_limit |
5 |
Result count when the model does not provide limit. |
max_search_limit |
20 |
Maximum accepted model-supplied search limit. |
The token decision uses a conservative local estimate of one token per four
Unicode characters. A provider-specific tokenizer can be accommodated by
passing a correspondingly reduced available_context_tokens; the crate never
claims that its estimate is a billing count.
Injector::new(catalog, config) validates configuration. Injector::inject()
returns None for an empty catalogue; otherwise it returns an Injection:
bridge_tool: the one function definition to append to the host's tools array;prompt: the system-prompt section containing either the tool or server manifest;inventory: structured form of that manifest for diagnostics;manifest_tokensandinventory_budget_tokens: threshold-observability values.
Injector::route(arguments) parses model-supplied bridge arguments and returns
a BridgeAction. An execute action includes an already-resolved McpTool; it
cannot be silently redirected to a different target.
Injector::handle(arguments, invoker) is a convenience layer. The supplied
McpInvoker receives the resolved real tool and arguments. Its implementation
is the correct place to perform authorization, approval, transport dispatch,
and result redaction.
The following is the intended integration point for a chat or agent loop:
use aster_mcp::{Injector, McpCatalog, McpTool, ProgressiveConfig};
use serde_json::json;
let catalogue = McpCatalog::new(vec![McpTool {
server: "github".into(),
name: "create_issue".into(),
description: "Create a GitHub issue in a repository.".into(),
input_schema: json!({ "type": "object", "properties": {
"title": { "type": "string" }
}, "required": ["title"] }),
}])?;
let injector = Injector::new(catalogue, ProgressiveConfig {
// Calculated by the host for this specific model turn.
available_context_tokens: 42_000,
..ProgressiveConfig::default()
})?;
if let Some(injection) = injector.inject() {
system_prompt.push_str("\n\n");
system_prompt.push_str(&injection.prompt);
tools.push(injection.bridge_tool);
}When the model calls aster_mcp, route it through an invoker that controls the
real MCP client:
use aster_mcp::{McpInvoker, McpTool};
use serde_json::Value;
struct ApprovedMcp<'a> { /* authenticated MCP sessions and policy */ }
impl McpInvoker for ApprovedMcp<'_> {
fn invoke(&mut self, tool: &McpTool, arguments: &Value) -> anyhow::Result<Value> {
// 1. Authorize `tool.id()` and these arguments.
// 2. Request user approval when required.
// 3. Dispatch MCP tools/call with tool.name and arguments.
// 4. Redact and audit the result.
todo!()
}
}
let result = injector.handle(&model_arguments, &mut approved_mcp)?;The current crate intentionally stops at this boundary; it does not configure or start MCP servers. That prevents a context optimization layer from gaining unreviewed access to credentials or bypassing Aster's policy engine.
aster-mcp carries no transport, so the CLI supplies them. All three speak the
same JSON-RPC session, and a server's transport changes only how bytes move.
| Transport | Declare it with | How it works |
|---|---|---|
stdio |
command, plus args, env, cwd |
A child process; one JSON line per message. |
streamable-http |
url, plus headers |
Each request is a POST. The reply comes back as JSON or as an SSE stream, and the session id the server assigns rides on every later request. |
sse |
url, plus headers |
The deprecated HTTP+SSE binding: one long-lived GET stream carries every reply, and messages are POSTed to the endpoint that stream names. |
type is optional. A server naming a command is stdio and one naming a url
is Streamable HTTP, which is what an entry written before remote support meant.
type: http is accepted as a spelling of streamable-http, since that is what
other clients write.
mcp:
servers:
deepwiki:
url: https://mcp.deepwiki.com/mcp
internal:
type: sse
url: https://tools.internal.example/sse
headers:
X-Tenant: acmeTwo rules apply to remote servers. Redirects are followed only inside one
origin, so configured headers never reach a host the user did not name; and a
session is ended with a DELETE at shutdown rather than left dangling.
Credentials belong in the environment or a secret store, not in headers in a
file you commit.
Aster serves a web server in-process, so its tools are in the catalogue with
no subprocess and nothing to install. web/search and web/extract need no API
key: they use Firecrawl's keyless tier when nothing else is configured, and fall
through to DuckDuckGo and a plain HTTP fetch when that is rate-limited.
| Tool | Keyless | Better with |
|---|---|---|
web/search |
Firecrawl keyless, then DuckDuckGo | EXA_API_KEY, PERPLEXITY_API_KEY, CONTEXT_DEV_API_KEY, FIRECRAWL_API_KEY, BROWSERBASE_API_KEY |
web/extract |
Firecrawl keyless, then Jina Reader, then DuckDuckGo, then plain HTTP | CONTEXT_DEV_API_KEY, FIRECRAWL_API_KEY, BROWSERBASE_API_KEY, EXA_API_KEY, or Cloudflare (both CLOUDFLARE_BR_ACCOUNT_ID and CLOUDFLARE_BR_API_TOKEN) |
web/crawl |
no | CONTEXT_DEV_API_KEY, FIRECRAWL_API_KEY, or the Cloudflare pair |
web/sitemap |
no | CONTEXT_DEV_API_KEY |
web/screenshot |
no | CONTEXT_DEV_API_KEY |
A configured provider takes over a tool it can serve, and the tool description
the model reads names whichever provider will answer it. Exa leads search
because that is what it is built for, with Perplexity next; the
extraction-first providers outrank it on extract.
Firecrawl's keyless tier is rate-limited per IP and cannot crawl. Set
FIRECRAWL_API_KEY to lift the limit, move Firecrawl up the dispatch order, and
enable web/crawl.
The same catalogue is available to other MCP clients as aster mcp serve web.
Aster serves a shortcuts server in-process on macOS, with two tools:
| Tool | What it does |
|---|---|
shortcuts/list |
Lists every shortcut available on this Mac |
shortcuts/run |
Runs a shortcut by name and returns its stdout |
list shells out to /usr/bin/shortcuts. run uses /usr/bin/osascript to
tell the Shortcuts app to run the shortcut, giving it GUI access for dialogs
that shortcuts run (headless) would block on. No configuration or API key
needed.
aster init scaffolds a browser server, disabled, running
browser-use over stdio. Enabling it
gives the agent a real Chromium to navigate, click, type, scroll, and screenshot.
aster mcp enable browserIt needs uv and Python 3.11+, plus a one-time
uvx browser-use install to fetch Chromium. Without them the server fails to
start, which is reported and skipped like any other broken server; the rest of
the session is unaffected.
Two of its tools are denied by default in the scaffolded config, because both
want a second LLM API key and retry_with_browser_use_agent runs a whole agent
loop inside one tool call:
mcp:
tools:
deny:
- "browser/browser_extract_content"
- "browser/retry_with_browser_use_agent"The scaffolded entry sets ANONYMIZED_TELEMETRY=False, since browser-use
reports usage to its vendor otherwise, and BROWSER_USE_HEADLESS=true, without
which a browser window opens on the user's screen. It browses in its own profile
under ~/.config/browseruse, not the Chrome the user is signed into.
BROWSER_USE_ALLOWED_DOMAINS confines it to named hosts.
Prefer web/extract for reading a page. The browser is for what a fetch cannot
do: JavaScript-heavy pages, screenshots, and clicking through a flow.
WebMCP is a W3C draft that lets
a web page expose tools to agents: the page calls
document.modelContext.registerTool({ name, description, inputSchema, execute }),
and an agent discovers and invokes those tools in the page, with the user's
session, while the user watches. A WebMCP page is, in the spec's own words, an
in-page MCP server.
aster-webmcp bridges that into the catalogue. It attaches to a tab in the
user's own Chrome or Edge over the DevTools protocol, injects a shim that
polyfills document.modelContext where the browser has no native WebMCP (only
experimental Chrome builds do today), and maps tools/list and tools/call
onto the page's registry. The page's tools appear as webmcp/<tool> and go
through the same policy and approval flow as any MCP tool.
It is opt-in, because it attaches to the browser the user is signed into:
mcp:
webmcp:
enabled: true
cdp_url: http://127.0.0.1:9222The browser must be started with its DevTools endpoint open:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222An unreachable browser, or one with no open tab, is reported and skipped like any other broken server; the session is unaffected. The tool list is live: it is re-read from the page whenever the catalogue is built, so tools a page registers or unregisters as its state changes show up without a restart.
Two caveats, both from the draft being young. Calls run with the user's
logged-in page session, which is the point and the risk: approval prompts are
what stand between the model and the user's accounts. And the shim covers the
imperative API only; the declarative <form> attribute API and exposedTo
origin scoping are not bridged yet.
aster mcp serve webmcp exposes the same bridge as a stdio MCP server for
other clients, reading ASTER_WEBMCP_CDP_URL for the endpoint.
mcp.tools filters the catalogue by server/tool id, the same id the policy
engine authorizes against. Globs are allowed, deny wins over allow, and an
empty allow means everything.
mcp:
tools:
allow: []
deny:
- "web/crawl"
- "browser/*"The filter runs once every server has listed, so it covers the in-process web
server and third-party ones alike. aster mcp disable web/crawl writes the id
into deny for you, aster mcp enable web/crawl takes it back out, and aster mcp list reports what the filter held back so a missing tool is never a
mystery. A name without a / still means a whole server, as it always did.
Global and project configs union their lists, so a project file that omits the
key cannot undo a global deny.
A tool that returns an MCP image content part now reaches the model as an
image. The bytes are re-encoded through the same path as @path mentions, so
the 2048px long-edge and 10MB caps apply to a full-page screenshot too.
The image rides a user message after the tool result rather than inside it:
OpenAI-shaped endpoints take content parts on a user turn and a bare string on a
tool turn. A model without image input gets [image content omitted] in its
place, which the AI layer already handles for every other image.
- Keep a small number of common, safety-critical native Aster tools directly
exposed.
aster-mcpapplies to external MCP tools, not Aster's core file and policy tools. - Use concise, user-language descriptions. Tool discovery is only as reliable as the catalogue metadata.
- Set a server's tools to the session catalogue only after authentication and capability filtering.
- Track manifest tokens, selected tool IDs, empty searches, describe-to-execute conversions, approval denials, and upstream failures. These reveal whether the catalogue or descriptions need improvement.
- Evaluate retrieval recall and wrong-tool calls on Aster tasks before changing the inventory budget. Smaller budgets reduce context use but can add a search round-trip for tools that would otherwise be obvious.
aster-mcp does not implement an MCP client transport, server installation,
OAuth, prompt caching, semantic/vector retrieval, or unrestricted tool
execution. Those belong to host-specific adapters and Aster's policy boundary.
The CLI's transports live in aster-cli/src/mcp, on the far side of that line.