feat(authlib): Add ContentSource capability for guardrail plugins#393
Conversation
Five small fixes from review of the ContentSource capability: - pipeline/content.go (InferenceExtension.Fragments): switch from make([]Fragment, 0, cap) + "if len==0 return nil" dance to `var out []Fragment`. Matches the nil-when-empty idiom used by A2AExtension and MCPExtension; the cap hint isn't measurable on this path. - pipeline/content.go (stringifyAny): log at DEBUG on json.Marshal error so the skip is observable in verbose runs rather than invisible. Tighten the docstring to call out the JSON-origin-data precondition for future callers whose values may not round-trip. - pipeline/content.go (normalizeA2ARole): reword the comment about the "raw value" escape hatch. Consumers going through the ContentSource interface cannot reach *A2AExtension.Role without a type assertion back to the concrete type — the original wording overstated what's available to generic guardrails. New wording: A2A-aware consumers may type-assert; framework-generic guardrails treat the normalized value as authoritative. - contracts/content.go (Fragment): add a forward-compat note on the struct recommending named-field initialization, so existing call sites and tests survive when fields are added (Path locator for violation citations, Kind hint for format-aware scanners). - docs/plugin-tutorial.md (Step 8): half-sentence pointer to A2AExtension.Fragments as the role-normalization reference, so readers copy-pasting the minimal example know to remap native role vocabularies to contracts.Role* constants. No behavior change. Tests still pass in authlib, cmd/authbridge, and cmd/abctl under GOWORK=off; vet clean; gofmt clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
|
Addressing review feedback in 548bd10. Five small fixes:
On review comment #5 (merge-order coordination with #392): acknowledged — the rebase is trivial in either direction. If #392 lands first, the |
| } | ||
|
|
||
| if e.Result != nil { | ||
| if items, ok := e.Result["content"].([]any); ok { |
There was a problem hiding this comment.
I'm nervous about this type assertion and others. They skip invalid content. Perhaps if ok is false a counter should be updated, or a message logged, so developers have a way of seeing malicious non-MCP payloads?
| } | ||
| } | ||
| } | ||
| return out |
There was a problem hiding this comment.
MCP also has an isError field and errors can carry text. Should an error messages also be considered a fragment?
Introduce a small capability interface so parser plugins can expose
their inspectable text to guardrail plugins (PII scrubbers, jailbreak
detectors, content classifiers, etc.) without the guardrails having to
import any specific parser package.
New package: authlib/contracts/
ContentSource interface {
Fragments() []Fragment
}
Fragment struct {
Role string // "user" | "assistant" | "system" | "tool" |
// "tool_args" | "tool_result" | ...
Text string
}
The contracts package is deliberately dependency-free so neither
producers nor consumers of the capability couple to each other.
Implementations on existing extensions (authlib/pipeline/content.go):
A2AExtension - text + data parts tagged with message role;
A2A's "agent" is normalized to "assistant" so
the vocabulary is uniform with Inference; final
artifact emitted as assistant on the response
phase; file parts skipped (not prose).
MCPExtension - tools/call name as role=tool; each argument
value as role=tool_args, JSON-stringified for
non-strings; response content items of
type=text as role=tool_result. Control-plane
RPCs (initialize, tools/list, etc.) return nil.
InferenceExtension - messages keyed by their existing role (OpenAI
"tool" role remapped to "tool_result" to match
MCP semantics); completion as assistant; each
tool call emits name + arguments as separate
fragments.
Consumer surface (authlib/pipeline/context.go):
pctx.ContentSources() []contracts.ContentSource
walks the named slots (Extensions.A2A / .MCP / .Inference) and returns
non-nil ones satisfying the interface. Guardrails iterate the result;
no framework knowledge of which protocols exist leaks into the
consumer.
Forward compatibility: when a protocols-registry refactor lands, the
helper's body rewrites to iterate a map - callers of
pctx.ContentSources() don't notice.
Docs:
plugin-reference.md - new "Exposing content to guardrails"
section with the role-mapping table
across A2A/MCP/Inference, a consumer
example, and guidance on when NOT to
implement (structure-oriented guardrails).
plugin-tutorial.md - Step 8 showing the 10-line opt-in.
framework-architecture.md - paragraph pointing to the capability
pattern.
Zero wire-format change. Zero behavioral change for pipelines without
guardrail plugins - ContentSources is never called when no consumer
exists.
Test plan:
- go build ./... and go test -race ./... pass in authlib,
cmd/authbridge, and cmd/abctl under GOWORK=off
- go vet ./... passes in all three modules
- Table-driven tests in pipeline/content_test.go cover role
normalization, empty/nil edge cases, non-string argument
stringification, control-plane RPC skip, and ContentSources
helper enumeration
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Five small fixes from review of the ContentSource capability: - pipeline/content.go (InferenceExtension.Fragments): switch from make([]Fragment, 0, cap) + "if len==0 return nil" dance to `var out []Fragment`. Matches the nil-when-empty idiom used by A2AExtension and MCPExtension; the cap hint isn't measurable on this path. - pipeline/content.go (stringifyAny): log at DEBUG on json.Marshal error so the skip is observable in verbose runs rather than invisible. Tighten the docstring to call out the JSON-origin-data precondition for future callers whose values may not round-trip. - pipeline/content.go (normalizeA2ARole): reword the comment about the "raw value" escape hatch. Consumers going through the ContentSource interface cannot reach *A2AExtension.Role without a type assertion back to the concrete type — the original wording overstated what's available to generic guardrails. New wording: A2A-aware consumers may type-assert; framework-generic guardrails treat the normalized value as authoritative. - contracts/content.go (Fragment): add a forward-compat note on the struct recommending named-field initialization, so existing call sites and tests survive when fields are added (Path locator for violation citations, Kind hint for format-aware scanners). - docs/plugin-tutorial.md (Step 8): half-sentence pointer to A2AExtension.Fragments as the role-normalization reference, so readers copy-pasting the minimal example know to remap native role vocabularies to contracts.Role* constants. No behavior change. Tests still pass in authlib, cmd/authbridge, and cmd/abctl under GOWORK=off; vet clean; gofmt clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Previous fixup (548bd10) reworded the normalizeA2ARole comment to describe type-assertion as the escape hatch for reading "the raw value." That framing was wrong: it accepted the premise that the interface hides role information consumers need, when the actual design is that Fragment.Role IS the role information, in a uniform vocabulary across every protocol. Rewrite the comment to state the intent directly: - Normalization to the standard vocabulary is the design goal, not a compromise. A guardrail compares f.Role == contracts.RoleUser once and works across A2A, MCP, and Inference. - Fragment.Role is authoritative — no type-assertion needed in the common case. - Type-assertion to *A2AExtension.Role is only appropriate for A2A-specialized tooling that wants the wire-level native value ("agent") verbatim, e.g., a protocol inspector. Framework-generic consumers should not do that. No behavior change. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Add a forward-reference paragraph to normalizeA2ARole explaining why this helper (and the corresponding Fragments methods on A2AExtension, MCPExtension, InferenceExtension) lives in pipeline/content.go rather than alongside its parser in plugins/. Short version: Go requires methods on a type to be declared in the type's own package, and the extension types currently live in pipeline/ as named slots on Extensions. A planned protocols-registry refactor will move the extension types into their parser packages (removing the named slots); at that point the methods and helpers move with them. Until then, the logical misplacement is marked in the code so a future reader understands it's a known shape, not an accident. No behavior change. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
PR rossoctl#393 review raised two concerns about MCPExtension.Fragments: 1. Silent type-assertion skips hide malformed (potentially malicious) non-conforming MCP payloads. A client that sends something other than a map for Params["arguments"] or an array for Result["content"] gets skipped with no signal anywhere. 2. JSON-RPC-level errors carry a Message field that may contain inspectable content (leaked credentials in "invalid url: ...", stack traces, PII from failed DB lookups). Today guardrails never see it — the error channel is a gap in coverage. Changes: - Log at DEBUG on each unexpected-shape skip inside MCPExtension.Fragments: * Params["arguments"] present but not map[string]any * Result["content"] present but not []any * Result.content[i] present but not map[string]any Full counter/metric wiring would be heavier than the concern warrants; a DEBUG log is the right level for "operator debugging a weird client." - Emit MCPError.Message as a role=tool_result fragment when Err is non-nil and Message is non-empty. A PII scrubber, credential detector, or content classifier now covers the error channel uniformly with normal tool output. Also updates the MCP row in plugin-reference.md's role-mapping table to note the tool_result role now covers both result text AND error messages. Tests: 5 new cases covering error-only, error-plus-result, empty-error-skip, malformed-arguments-skip, and malformed-result-skip. No behavior change for well-formed MCP traffic. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
d08c9b2 to
a2e331b
Compare
|
Rebased onto current Addressing the two inline reviews in "skip invalid content silently" — Now logs at DEBUG on each unexpected-shape skip inside
A full counter / metric would be heavier than the concern warrants — DEBUG is the right level for "operator debugging a weird client." If the project later adds structured metrics to the pipeline, wiring these counters in is additive. "MCP errors can carry text — consider them fragments" — 5 new test cases cover: error-only, error-plus-result, empty-error-skip, malformed-arguments-skip, malformed-result-skip. |
Summary
Adds a small capability interface so parser plugins can expose their inspectable text to guardrail plugins (PII scrubbers, jailbreak detectors, content classifiers, prompt-injection filters, etc.) without the guardrails having to import any specific parser package.
Today, a guardrail that wants to scan user-provided text has to branch on every protocol it might encounter:
With this PR:
What's in it
New package
authlib/contracts/— dependency-free, defines:Plus standard role constants (
RoleUser,RoleAssistant,RoleSystem,RoleTool,RoleToolArgs,RoleToolResult). Vocabulary is open — protocols that don't fit any value may emit their own role strings.Fragments()implementations on existing extensions (authlib/pipeline/content.go), with compile-time assertions:A2AExtensionagent→assistant); file parts skippedassistantMCPExtensiontools/callname astool; each argument value astool_args(JSON-stringified); control-plane RPCs return nilcontent[]text items astool_resultInferenceExtensiontool→tool_result)assistant; tool calls emit name + argsConsumer helper —
pctx.ContentSources()walksExtensions.A2A/.MCP/.Inferenceand returns non-nil ones satisfying the interface. Forward-compatible: when a protocols-registry refactor lands, the body rewrites to iterate a map — callers don't notice.Docs —
plugin-reference.mdgets a new "Exposing content to guardrails" section with the role-mapping table, a consumer example, and guidance on when NOT to implement (structure-oriented guardrails).plugin-tutorial.mdgets Step 8 showing the ~10-line opt-in.framework-architecture.md§4 cross-references.What's NOT in it
Fragments()is a Go method; JSON marshalling is unaffected.cmd/abctlbuilds and tests unchanged — the method is available on the decoded Go types automatically.Test plan
go build ./...in authlib, cmd/authbridge, cmd/abctl underGOWORK=offgo test -race ./...passes in all three modulesgo vet ./...clean in all three modulesgofmt -lclean on all files touched by this PRagent→assistant, Inferencetool→tool_result), empty/nil edge cases, non-string argument JSON-stringification, control-plane RPC skip,ContentSources()enumeration and uniform iterationScope for follow-ups
Pathfield onFragmentfor violation-report citations (deferred — current shape covers the 80% content-scanning case).MutableContentSourcefor inline redaction (deferred — depends on chained-mutator work).Assisted-By: Claude (Anthropic AI) noreply@anthropic.com