10 unstable releases (3 breaking)
| new 0.9.0 | May 16, 2026 |
|---|---|
| 0.8.1 | May 14, 2026 |
| 0.7.2 | May 13, 2026 |
| 0.6.5 | May 9, 2026 |
#469 in Development tools
Used in 3 crates
1MB
21K
SLoC
gaze-mcp-core
Adding
gaze-mcp-core(v0.7.2) to your crate enables: the transport-free MCP chokepoint runtime —Tooltrait,PiiEnvelope::dispatch,ToolRegistry,ManifestStore/AuthHook/SessionIdPolicyplug-in points, and the optionalcore-toolsagent-tier tool set. Does NOT bring in: an MCP transport. No stdio, no streamable HTTP, no JSON-RPC framing — this crate is transport-free by design. For the rmcp transport sink, also addgaze-mcp-rmcp(or implementFrontendyourself; see below).
Scope
gaze-mcp enforces the chokepoint on the data-source ↔ model path. Any data flowing from a source through an MCP tool to the model passes through PiiEnvelope::dispatch and is redacted before the model sees it.
gaze-mcp does not cover the user ↔ model path. Pasted text, uploaded files, and screenshots in the agent host's chat UI reach the model unredacted. For that axis, see gaze-proxy (planned for v0.8 — multi-vendor reverse proxy supporting Anthropic, OpenAI, Gemini).
gaze-mcp-core is the transport-free runtime for Gaze's MCP chokepoint. It
exposes a Tool trait, a sealed ToolCtx, a ToolRegistry, the
PiiEnvelope::dispatch chokepoint, the Frontend / DispatchHost
plug-in points, the ManifestStore contract, and the AuthHook +
SessionIdPolicy policy surfaces. Transports (rmcp stdio/http, custom
JSON-RPC, …) live in sink crates that depend on this one.
The architectural rationale, per-axis tradeoffs, and the data-source vs
user-input scope split are summarized in the boundary statement above and
captured in the v0.7.0 CHANGELOG entry. The transport-free runtime ships
alongside the gaze-mcp-rmcp sink as of v0.7.0.
What gets enforced
PiiEnvelope::dispatch runs every tool call through the same sealed
ordering — redact args → manifest.begin → invoke → redact response →
manifest.finish (or fail) → return. The ordering is hard-coded inside
the dispatcher; tools never see a path around it because:
ToolCtx::newispub(crate). The dispatcher is the only construction site for tool contexts. Verified by the trybuild compile-fail fixtures intests/ui/.ToolCtx's fields arepub(crate)plus#[non_exhaustive]. External crates cannot construct one via struct literal or..Default::default()either.- The context's lifetime
'abinds it to the dispatcher's stack frame — tools can't stash a reference past the call. ToolRegistry::registeronly accepts types that implement theTooltrait. There is noregister_raw(Box<dyn Fn(JsonValue) -> JsonValue>)escape hatch.
The combination is the type-level chokepoint guarantee adopters depend on.
The behavioral cousins — that the manifest store actually receives
begin_call before invoke, and finish_call / fail_call before the
response escapes — are checked in tests/chokepoint_ordering.rs.
Snapshot refs use sha256(audit_session_id || 0x00 || call_id || 0x00 || payload_bytes). This is an integrity marker for audit lookup, not a secret
commitment scheme. Threat model: anyone who can read audit rows and guess the
exact response payload can verify that guess offline. Current v0.7.x stable accepts
that risk because audit readers are already trusted with manifest metadata and
operator-tier deployments must protect snapshot storage. If that trust boundary
changes, replace the hash input with keyed HMAC material owned by the
ManifestStore implementation.
Session ownership boundary
⚠️ One
gaze::Sessionper authorization boundary. ASessiontracks the entire token↔raw map for its lifetime in a singleDashMap(crates/gaze/src/session.rs:299-304). The operator-tier toolexport_session_tokens(Tool 3) dumps that entire map in the clear — every token from every conversation that ever shared the Session is exposed in one call. There is no per-call provenance filter onSession::tokens()in v0.7.If your host process serves multiple agents or users concurrently, your code MUST construct a fresh
Sessionper authorization domain and supply it toPiiEnvelope::new. Sharing a singleSessionacross conversations is a critical leak: agent B's operator-tier principal callingexport_session_tokensreads agent A's PII inventory.Type-level enforcement of this boundary is deferred to v0.8.
Operator-tier tools and audit storage
Operator-tier tools (restore, restore_strict, export_session_tokens)
are privileged by design. Their descriptors set
ResponseRedaction::BypassByOperator, so the dispatcher returns their raw
payloads to an operator principal after AuthHook::authorize_operator
passes. Agent-tier tools cannot opt into this posture; registry validation
rejects it and the dispatcher fails closed if that invariant is ever broken.
Successful operator-tier responses are still committed through
ManifestStore::finish_call before returning. For these tools, the response
snapshot can contain raw PII: restored values or the complete session token
inventory. ManifestStore implementations that persist snapshots must protect
those bytes with encryption at rest and operator-only read access. Audit rows
record snapshot locators plus the salted SHA-256 integrity marker described
above; audit readers are trusted to see manifest metadata and to verify guessed
payloads offline under the v0.7.x threat model.
Adopter quickstart
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use gaze_mcp_core::{
AuthHook, AuthError, BeginCallContext, CallHandle, FailureReason,
ManifestError, ManifestStore, PiiEnvelope, Principal,
SessionIdPolicy, SnapshotRef, Tool, ToolCtx, ToolDescriptor,
ToolError, ToolRegistry, ToolResponse,
};
// 1. Implement `ManifestStore` against your backing store.
struct MyManifest { /* … */ }
#[async_trait]
impl ManifestStore for MyManifest {
async fn begin_call(&self, ctx: BeginCallContext<'_>) -> Result<CallHandle, ManifestError> {
// Persist `ctx.call_id`, `ctx.principal_id`, `ctx.tool_name`,
// `ctx.redacted_args`, `ctx.started_at`, optionally bind to
// `ctx.external_session_id`.
Ok(CallHandle::new(ctx.call_id))
}
async fn finish_call(
&self,
_handle: CallHandle,
_snapshot: SnapshotRef,
) -> Result<(), ManifestError> {
// Record the redacted-response snapshot reference.
Ok(())
}
async fn fail_call(
&self,
_handle: CallHandle,
_reason: FailureReason,
) -> Result<(), ManifestError> {
// Record the failure reason. Must always succeed in chokepoint
// ordering — return Err only when the backing store is genuinely
// unavailable.
Ok(())
}
}
// 2. Implement `AuthHook` to gate dispatch.
struct MyAuth;
#[async_trait]
impl AuthHook for MyAuth {
async fn authorize_agent(&self, _p: &Principal, _tool: &str) -> Result<(), AuthError> {
Ok(())
}
async fn authorize_operator(&self, _p: &Principal, _tool: &str) -> Result<(), AuthError> {
Err(AuthError::Denied("operators must use the admin path".into()))
}
}
// 3. Build the gaze pipeline + session per conversation.
let pipeline = gaze::Pipeline::builder().build().expect("pipeline");
let session = gaze::Session::new(gaze::Scope::Ephemeral).expect("session");
// 4. Register tools.
let mut registry = ToolRegistry::new();
# #[cfg(feature = "core-tools")]
registry.register(gaze_mcp_core::core_tools::CleanTool::new()).unwrap();
// 5. Build the envelope; pass to the transport via `Frontend::serve`.
let manifest = MyManifest {};
let auth = MyAuth;
let policy = SessionIdPolicy::default_strict();
let _envelope = PiiEnvelope::new(®istry, &auth, &manifest, &pipeline, &session, &[], &policy);
The transport sink (e.g. gaze-mcp-rmcp::RmcpFrontend) wraps the envelope
behind the DispatchHost trait and calls Frontend::serve from the
adopter's tokio runtime.
Implementing your own Frontend
Adopters who do not want rmcp implement Frontend themselves. The
contract is one method (serve(self, host, shutdown) -> Result<(), FrontendError>)
that drives the transport's accept-and-dispatch loop until
ShutdownToken::cancel fires. The host (Arc<dyn DispatchHost>)
wraps the envelope behind a narrow surface (dispatch + list_tools)
so the transport never sees the gaze pipeline, the gaze session, or the
manifest store directly.
A reference adapter for rmcp's tools/list + tools/call shape lives in
crates/gaze-mcp-rmcp (companion PR).
Cargo features
| Feature | Default | Adds |
|---|---|---|
core-tools |
yes | core_tools::{CleanTool, TokenizeFieldTool, SafetyNetCheckTool} registrations. |
operator-tier |
no | operator_tools::{RestoreTool, RestoreStrictTool, ExportSessionTokensTool}. Tools route through AuthHook::authorize_operator. |
The operator-tier feature is opt-in. Default builds expose only the
agent surface, so an adopter who skips wiring auth hits
AuthError::MissingHook from DenyAllAuthHook instead of accidentally
exposing restore.
Open items
gaze-mcp-rmcpships alongside this crate as the reference rmcp transport sink.gaze-proxy(the user-input axis sibling, multi-vendor LLM API reverse proxy) is planned for v0.8 — see the## Scopeboundary statement at the top of this README.
See docs/architecture/mcp-runtime.md
for the full chokepoint contract + audit-row schema + threat model.
The ToolCtx audit-correlation surface (call_id, tool_name,
principal_id, audit_session_id), the ManifestStore lifecycle methods
(begin_call / finish_call / fail_call), the closed FailureReason
set, and the AuthHook decision audit are cataloged in
docs/metrics.md.
Dependencies
~34MB
~595K SLoC