From a9d451baaf7b1337e0877768e394daf527f010ec Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 10 May 2026 10:58:54 -0400 Subject: [PATCH 1/5] feat(authlib): Add ContentSource capability for guardrail plugins 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) Signed-off-by: Hai Huang --- authbridge/authlib/contracts/content.go | 79 +++++ authbridge/authlib/pipeline/content.go | 187 ++++++++++ authbridge/authlib/pipeline/content_test.go | 371 ++++++++++++++++++++ authbridge/authlib/pipeline/context.go | 31 ++ authbridge/docs/framework-architecture.md | 2 + authbridge/docs/plugin-reference.md | 94 +++++ authbridge/docs/plugin-tutorial.md | 35 ++ 7 files changed, 799 insertions(+) create mode 100644 authbridge/authlib/contracts/content.go create mode 100644 authbridge/authlib/pipeline/content.go create mode 100644 authbridge/authlib/pipeline/content_test.go diff --git a/authbridge/authlib/contracts/content.go b/authbridge/authlib/contracts/content.go new file mode 100644 index 000000000..2d70101c7 --- /dev/null +++ b/authbridge/authlib/contracts/content.go @@ -0,0 +1,79 @@ +// Package contracts defines capability interfaces that protocol +// extensions implement to participate in framework services like content +// inspection, session bucketing, and summarization. The package is +// deliberately dependency-free — parser plugins and consumer plugins +// (guardrails) both import it without importing each other, so no +// implicit coupling forms between producers and consumers of a +// capability. +// +// Capability interfaces are checked by consumers via type assertion. A +// protocol extension that doesn't implement one simply isn't offered +// that capability; guardrails skip it. +package contracts + +// ContentSource is implemented by protocol extensions whose payload +// contains user-visible text that guardrails might inspect — PII +// scrubbing, jailbreak detection, content classification, credential +// leakage scanning, and similar content-oriented checks. +// +// Usage on the consumer side: +// +// for _, src := range pctx.ContentSources() { +// for _, f := range src.Fragments() { +// if f.Role == contracts.RoleUser { +// scan(f.Text) +// } +// } +// } +// +// The guardrail does not import any parser package — it knows only +// this contract. When a new protocol ships with a Fragments +// implementation, existing guardrails pick it up automatically. +// +// Protocols that carry no inspectable text (control-plane RPCs like +// MCP initialize, binary protocols, identity-only auth messages) simply +// do not implement this interface. +type ContentSource interface { + // Fragments returns every inspectable text fragment on the current + // request or response phase, in document order. Returns nil or an + // empty slice when there's nothing to inspect at this phase. + // Fragments with empty Text are filtered by the producer. + Fragments() []Fragment +} + +// Fragment is one inspectable piece of text, tagged with the role that +// produced it. +type Fragment struct { + // Role identifies who produced the text. Use the standard values + // below when the semantic fit is clear; protocols that don't fit + // any standard value may emit their own role strings. + Role string + + // Text is the raw text content. Never empty — producers filter + // empty fragments before returning. + Text string +} + +// Standard Role values. Parsers reference these instead of string +// literals so spelling is compiler-checked. Guardrails may compare +// against either the constant or a string literal — the over-the-wire +// representation is the same. +// +// - RoleUser: end-user input, human-authored. +// - RoleAssistant: model or agent output. +// - RoleSystem: system prompt or instructions to the model. +// - RoleTool: the name of a tool being invoked. +// - RoleToolArgs: an argument value passed to a tool invocation. +// - RoleToolResult: content returned from a tool invocation. +// +// The vocabulary is open. A protocol that invents a role outside this +// list is valid; guardrails that don't recognize a role treat it per +// their own policy (typically: scan anyway, or skip). +const ( + RoleUser = "user" + RoleAssistant = "assistant" + RoleSystem = "system" + RoleTool = "tool" + RoleToolArgs = "tool_args" + RoleToolResult = "tool_result" +) diff --git a/authbridge/authlib/pipeline/content.go b/authbridge/authlib/pipeline/content.go new file mode 100644 index 000000000..4f3fc2e25 --- /dev/null +++ b/authbridge/authlib/pipeline/content.go @@ -0,0 +1,187 @@ +package pipeline + +import ( + "encoding/json" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" +) + +// This file wires the named protocol extensions (A2AExtension, +// MCPExtension, InferenceExtension) to contracts.ContentSource. The +// methods live alongside their receiver types rather than with the +// parser plugins because Go only allows defining methods on a type +// from that type's own package. +// +// Compile-time assertions make the implementation visible to +// grep/LSP and catch interface drift early. +var ( + _ contracts.ContentSource = (*A2AExtension)(nil) + _ contracts.ContentSource = (*MCPExtension)(nil) + _ contracts.ContentSource = (*InferenceExtension)(nil) +) + +// Fragments implements contracts.ContentSource for A2A messages. +// +// Request-phase: iterates message Parts, emitting text and data parts +// tagged with the message role (normalized: A2A's native "agent" role +// is rewritten to "assistant" so guardrails match a single vocabulary +// across A2A and Inference). File parts carry URIs or base64 blobs, +// not prose; they're skipped. +// +// Response-phase: the final artifact is assistant-authored text. +func (e *A2AExtension) Fragments() []contracts.Fragment { + if e == nil { + return nil + } + var out []contracts.Fragment + + role := normalizeA2ARole(e.Role) + for _, p := range e.Parts { + switch p.Kind { + case "text", "data": + if p.Content != "" { + out = append(out, contracts.Fragment{Role: role, Text: p.Content}) + } + case "file": + // File parts carry URIs or base64 blobs; not inspectable as + // prose. A dedicated file-scanning guardrail can type-assert + // to *A2AExtension and access the raw Parts directly. + } + } + + if e.Artifact != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleAssistant, Text: e.Artifact}) + } + return out +} + +// normalizeA2ARole rewrites A2A's native role vocabulary to match the +// Inference / OpenAI-style vocabulary. Keeping guardrails to a single +// role set across protocols is worth the small loss of A2A fidelity — +// callers that need the raw value read *A2AExtension.Role directly. +func normalizeA2ARole(r string) string { + switch r { + case "agent": + return contracts.RoleAssistant + case "user": + return contracts.RoleUser + default: + // Unknown / unset roles pass through so guardrails at least + // see something to filter on. Empty string is tolerated too. + return r + } +} + +// Fragments implements contracts.ContentSource for MCP messages. +// +// Request-phase: only tools/call is modeled — it's the one MCP method +// carrying user-intent content. Control-plane calls (initialize, ping, +// tools/list, resources/list, etc.) return nil. The tool name is +// emitted as role=tool; each argument value is emitted as +// role=tool_args, JSON-stringified if non-string. +// +// Response-phase: MCP tool results are conventionally shaped as +// {"content": [{"type":"text","text":"..."}, {"type":"image",...}, ...]}. +// Text items are emitted with role=tool_result; non-text items are +// skipped as not inspectable. +func (e *MCPExtension) Fragments() []contracts.Fragment { + if e == nil { + return nil + } + var out []contracts.Fragment + + if e.Method == "tools/call" && e.Params != nil { + if name, _ := e.Params["name"].(string); name != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleTool, Text: name}) + } + if args, ok := e.Params["arguments"].(map[string]any); ok { + for _, v := range args { + text := stringifyAny(v) + if text != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleToolArgs, Text: text}) + } + } + } + } + + if e.Result != nil { + if items, ok := e.Result["content"].([]any); ok { + for _, it := range items { + m, ok := it.(map[string]any) + if !ok { + continue + } + if m["type"] != "text" { + continue + } + if t, _ := m["text"].(string); t != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleToolResult, Text: t}) + } + } + } + } + return out +} + +// Fragments implements contracts.ContentSource for Inference messages. +// +// Request-phase: walks the Messages slice. OpenAI's role vocabulary +// maps to our standard values directly, except that OpenAI's "tool" +// role marks a tool RESULT in the conversation history — remapped to +// "tool_result" so it lines up with MCP's tool result semantics. +// +// Response-phase: the model's completion (assistant) plus any tool +// calls the model emitted (tool name + arguments as separate fragments). +func (e *InferenceExtension) Fragments() []contracts.Fragment { + if e == nil { + return nil + } + out := make([]contracts.Fragment, 0, len(e.Messages)+1+2*len(e.ToolCalls)) + + for _, m := range e.Messages { + if m.Content == "" { + continue + } + role := m.Role + if role == "tool" { + role = contracts.RoleToolResult + } + out = append(out, contracts.Fragment{Role: role, Text: m.Content}) + } + + if e.Completion != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleAssistant, Text: e.Completion}) + } + for _, tc := range e.ToolCalls { + if tc.Name != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleTool, Text: tc.Name}) + } + if tc.Arguments != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleToolArgs, Text: tc.Arguments}) + } + } + + if len(out) == 0 { + return nil + } + return out +} + +// stringifyAny renders an arbitrary argument value as a string suitable +// for text scanning. Strings pass through unchanged; anything else +// goes through JSON so nested maps / slices become flat inspectable +// text. A marshal error (should be rare for JSON-origin data) yields +// empty string, which the caller filters. +func stringifyAny(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} diff --git a/authbridge/authlib/pipeline/content_test.go b/authbridge/authlib/pipeline/content_test.go new file mode 100644 index 000000000..af3b308fd --- /dev/null +++ b/authbridge/authlib/pipeline/content_test.go @@ -0,0 +1,371 @@ +package pipeline + +import ( + "reflect" + "sort" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" +) + +func TestA2AExtension_Fragments(t *testing.T) { + tests := []struct { + name string + ext *A2AExtension + want []contracts.Fragment + }{ + { + name: "nil_receiver", + ext: nil, + want: nil, + }, + { + name: "user_text_part", + ext: &A2AExtension{ + Role: "user", + Parts: []A2APart{ + {Kind: "text", Content: "What's the weather in SF?"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: "What's the weather in SF?"}, + }, + }, + { + name: "agent_role_normalized_to_assistant", + ext: &A2AExtension{ + Role: "agent", + Parts: []A2APart{ + {Kind: "text", Content: "The weather is sunny."}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleAssistant, Text: "The weather is sunny."}, + }, + }, + { + name: "data_part_emitted", + ext: &A2AExtension{ + Role: "user", + Parts: []A2APart{ + {Kind: "data", Content: `{"lat":37.7}`}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: `{"lat":37.7}`}, + }, + }, + { + name: "file_part_skipped", + ext: &A2AExtension{ + Role: "user", + Parts: []A2APart{ + {Kind: "file", Content: "https://example.com/doc.pdf"}, + {Kind: "text", Content: "Summarize this"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: "Summarize this"}, + }, + }, + { + name: "empty_text_content_filtered", + ext: &A2AExtension{ + Role: "user", + Parts: []A2APart{ + {Kind: "text", Content: ""}, + {Kind: "text", Content: "real content"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: "real content"}, + }, + }, + { + name: "artifact_emitted_as_assistant", + ext: &A2AExtension{ + Role: "user", + Parts: []A2APart{{Kind: "text", Content: "Tell me a joke"}}, + Artifact: "Why did the chicken cross the road?", + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: "Tell me a joke"}, + {Role: contracts.RoleAssistant, Text: "Why did the chicken cross the road?"}, + }, + }, + { + name: "unknown_role_passes_through", + ext: &A2AExtension{ + Role: "custom-role", + Parts: []A2APart{ + {Kind: "text", Content: "hi"}, + }, + }, + want: []contracts.Fragment{ + {Role: "custom-role", Text: "hi"}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.ext.Fragments() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestMCPExtension_Fragments(t *testing.T) { + tests := []struct { + name string + ext *MCPExtension + want []contracts.Fragment + }{ + { + name: "nil_receiver", + ext: nil, + want: nil, + }, + { + name: "tools_list_no_content", + ext: &MCPExtension{Method: "tools/list"}, + want: nil, + }, + { + name: "tools_call_with_string_arg", + ext: &MCPExtension{ + Method: "tools/call", + Params: map[string]any{ + "name": "fetch_url", + "arguments": map[string]any{"url": "https://example.com"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleTool, Text: "fetch_url"}, + {Role: contracts.RoleToolArgs, Text: "https://example.com"}, + }, + }, + { + name: "tools_call_non_string_arg_json_stringified", + ext: &MCPExtension{ + Method: "tools/call", + Params: map[string]any{ + "name": "set_preferences", + "arguments": map[string]any{"prefs": map[string]any{"theme": "dark"}}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleTool, Text: "set_preferences"}, + {Role: contracts.RoleToolArgs, Text: `{"theme":"dark"}`}, + }, + }, + { + name: "tools_call_missing_name", + ext: &MCPExtension{ + Method: "tools/call", + Params: map[string]any{ + "arguments": map[string]any{"q": "hello"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleToolArgs, Text: "hello"}, + }, + }, + { + name: "tools_call_nil_params", + ext: &MCPExtension{Method: "tools/call"}, + want: nil, + }, + { + name: "response_text_item", + ext: &MCPExtension{ + Method: "tools/call", + Result: map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "Result line"}, + }, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleToolResult, Text: "Result line"}, + }, + }, + { + name: "response_non_text_item_skipped", + ext: &MCPExtension{ + Method: "tools/call", + Result: map[string]any{ + "content": []any{ + map[string]any{"type": "image", "data": "base64..."}, + map[string]any{"type": "text", "text": "caption"}, + }, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleToolResult, Text: "caption"}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.ext.Fragments() + // Map iteration over Params["arguments"] is non-deterministic; + // sort both sides by (Role, Text) before comparing so multi-arg + // cases compare stably. Single-arg cases already line up. + sortFragments(got) + sortFragments(tc.want) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestInferenceExtension_Fragments(t *testing.T) { + tests := []struct { + name string + ext *InferenceExtension + want []contracts.Fragment + }{ + { + name: "nil_receiver", + ext: nil, + want: nil, + }, + { + name: "system_and_user_messages", + ext: &InferenceExtension{ + Messages: []InferenceMessage{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hi"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleSystem, Text: "You are helpful."}, + {Role: contracts.RoleUser, Text: "Hi"}, + }, + }, + { + name: "tool_role_remapped_to_tool_result", + ext: &InferenceExtension{ + Messages: []InferenceMessage{ + {Role: "user", Content: "weather?"}, + {Role: "assistant", Content: "let me check"}, + {Role: "tool", Content: "18°C sunny"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: "weather?"}, + {Role: contracts.RoleAssistant, Text: "let me check"}, + {Role: contracts.RoleToolResult, Text: "18°C sunny"}, + }, + }, + { + name: "empty_content_filtered", + ext: &InferenceExtension{ + Messages: []InferenceMessage{ + {Role: "user", Content: ""}, + {Role: "user", Content: "real"}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleUser, Text: "real"}, + }, + }, + { + name: "completion_emitted_as_assistant", + ext: &InferenceExtension{ + Completion: "The answer is 42.", + }, + want: []contracts.Fragment{ + {Role: contracts.RoleAssistant, Text: "The answer is 42."}, + }, + }, + { + name: "tool_calls_emit_name_and_args", + ext: &InferenceExtension{ + ToolCalls: []InferenceToolCall{ + {Name: "get_weather", Arguments: `{"city":"SF"}`}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleTool, Text: "get_weather"}, + {Role: contracts.RoleToolArgs, Text: `{"city":"SF"}`}, + }, + }, + { + name: "tool_call_empty_name_or_args_skipped", + ext: &InferenceExtension{ + ToolCalls: []InferenceToolCall{ + {Name: "", Arguments: `{}`}, + {Name: "call_me", Arguments: ""}, + }, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleToolArgs, Text: "{}"}, + {Role: contracts.RoleTool, Text: "call_me"}, + }, + }, + { + name: "empty_extension_returns_nil", + ext: &InferenceExtension{}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tc.ext.Fragments() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestContentSources(t *testing.T) { + // Empty context → empty slice + c := &Context{} + if got := c.ContentSources(); len(got) != 0 { + t.Errorf("empty context: want 0 sources, got %d", len(got)) + } + + // Populated with all three + c = &Context{ + Extensions: Extensions{ + A2A: &A2AExtension{Role: "user", Parts: []A2APart{{Kind: "text", Content: "hi"}}}, + MCP: &MCPExtension{Method: "tools/list"}, + Inference: &InferenceExtension{Messages: []InferenceMessage{{Role: "user", Content: "x"}}}, + }, + } + srcs := c.ContentSources() + if len(srcs) != 3 { + t.Fatalf("want 3 sources, got %d", len(srcs)) + } + + // Sanity: iterating and collecting user fragments works uniformly + var userTexts []string + for _, s := range srcs { + for _, f := range s.Fragments() { + if f.Role == contracts.RoleUser { + userTexts = append(userTexts, f.Text) + } + } + } + sort.Strings(userTexts) + want := []string{"hi", "x"} + if !reflect.DeepEqual(userTexts, want) { + t.Errorf("user texts: got %v, want %v", userTexts, want) + } +} + +// sortFragments sorts by (Role, Text) so map-order non-determinism in +// MCP's arguments iteration doesn't flake the test comparisons. +func sortFragments(f []contracts.Fragment) { + sort.Slice(f, func(i, j int) bool { + if f[i].Role != f[j].Role { + return f[i].Role < f[j].Role + } + return f[i].Text < f[j].Text + }) +} diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index abdc4cf00..dedf50114 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -7,6 +7,8 @@ import ( "log/slog" "net/http" "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" ) // Identity carries the subject identity established by whichever auth @@ -257,6 +259,35 @@ func (c *Context) BodyMutated() bool { return c.bodyMutated } // ResponseBodyMutated is the response-side analogue of BodyMutated. func (c *Context) ResponseBodyMutated() bool { return c.responseBodyMutated } +// ContentSources returns every protocol extension on this Context that +// implements contracts.ContentSource. Guardrail plugins call this to +// iterate inspectable text across whatever protocol a request happens +// to carry, without importing any specific parser package: +// +// for _, src := range pctx.ContentSources() { +// for _, f := range src.Fragments() { +// if f.Role == contracts.RoleUser { scan(f.Text) } +// } +// } +// +// Order is A2A, MCP, Inference — but guardrails shouldn't rely on it; +// treat the result as an unordered set. Returns an empty slice when no +// parser produced an extension or when none of the populated extensions +// implement ContentSource. +func (c *Context) ContentSources() []contracts.ContentSource { + out := make([]contracts.ContentSource, 0, 3) + if c.Extensions.A2A != nil { + out = append(out, c.Extensions.A2A) + } + if c.Extensions.MCP != nil { + out = append(out, c.Extensions.MCP) + } + if c.Extensions.Inference != nil { + out = append(out, c.Extensions.Inference) + } + return out +} + // emitBodyMutation records the Invocation and publishes the // plugin-public event carrying length delta + sha256 before/after. // Never logs raw body bytes — the session store is unauthenticated. diff --git a/authbridge/docs/framework-architecture.md b/authbridge/docs/framework-architecture.md index 6cb1e11a1..31cabe360 100644 --- a/authbridge/docs/framework-architecture.md +++ b/authbridge/docs/framework-architecture.md @@ -216,6 +216,8 @@ A parser populates its slot AND records an Invocation with `ActionObserve`. The Adding a named slot is an authlib-core change: edit `Extensions`, add a wire field on `sessionEventWire`, update `snapshotXXX` helpers in the listener, and add filtering rules in `abctl`. +**Capability interfaces on the slot types.** Named-slot extensions may implement optional capability interfaces declared in [`authlib/contracts/`](../authlib/contracts/) so consumer plugins can interact with them without importing any specific parser package. The current capability is [`ContentSource`](../authlib/contracts/content.go) — implemented by `A2AExtension`, `MCPExtension`, and `InferenceExtension` — which lets guardrail plugins iterate inspectable text fragments via `pctx.ContentSources()`. Parser authors opt in by adding one method (`Fragments() []contracts.Fragment`); consumers see a uniform view across every protocol that implements the contract. See [`plugin-reference.md` "Exposing content to guardrails"](./plugin-reference.md#exposing-content-to-guardrails) for the pattern. + ### `Custom map[string]any` — plugin-private state + escape-hatch public events Two access patterns share the same map, disambiguated by key suffix. diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md index d48a11fac..a87c565f0 100644 --- a/authbridge/docs/plugin-reference.md +++ b/authbridge/docs/plugin-reference.md @@ -507,6 +507,100 @@ session store is unauthenticated. Plugin-private debug logs may include body bytes at DEBUG level, but never publish them to the session stream or Custom map. +## Exposing content to guardrails + +Parser plugins whose extensions carry user-visible text (message bodies, +tool arguments, LLM completions) can opt into a shared content-inspection +contract by implementing [`contracts.ContentSource`](../authlib/contracts/content.go). +Guardrail plugins (PII scrubbers, jailbreak detectors, content classifiers, +prompt-injection filters, etc.) iterate the contract via +`pctx.ContentSources()` and never import any specific parser package. + +This section is optional. A parser that doesn't implement `ContentSource` +still works for session bucketing and abctl rendering; it just isn't visible +to content-oriented guardrails. + +### The contract + +```go +type ContentSource interface { + Fragments() []Fragment +} + +type Fragment struct { + Role string // "user" | "assistant" | "system" | "tool" | "tool_args" | "tool_result" | ... + Text string // non-empty; producers filter empties +} +``` + +Constants for the standard role values live in the `contracts` package +(`RoleUser`, `RoleAssistant`, `RoleSystem`, `RoleTool`, `RoleToolArgs`, +`RoleToolResult`). Parsers should use them when the semantic fit is clear. +The vocabulary is open — a protocol that carries a role outside this list +may emit its own string; guardrails that don't recognize it treat it per +their own policy. + +### Role mapping across in-tree parsers + +| Role | MCP source | A2A source | Inference source | +|---|---|---|---| +| `user` | — | user text parts | user messages | +| `assistant` | — | artifact | completion | +| `system` | — | — | system messages | +| `tool` | tools/call name | — | model's tool call name | +| `tool_args` | tools/call argument values | — | model's tool call arguments | +| `tool_result` | tools/call result text | — | conversation's prior tool messages | + +Empty cells are intentional. A2A has no system-prompt concept; MCP has no +user-message concept. Guardrails ignore roles they don't care about; no +fabricated content fills the gaps. + +### Implementing Fragments + +Keep each implementation to a pure function over the extension's fields. +Skip fragments with empty text — consumers never want zero-length entries. +Stringify non-string values with `json.Marshal` so nested maps/slices +become flat inspectable text. Reference implementations live alongside +the types in [`authlib/pipeline/content.go`](../authlib/pipeline/content.go). + +### Consuming content in a guardrail + +```go +import "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" + +func (p *JailbreakDetector) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + for _, src := range pctx.ContentSources() { + for _, f := range src.Fragments() { + // Jailbreak attempts come through user input and tool_args. + if f.Role != contracts.RoleUser && f.Role != contracts.RoleToolArgs { + continue + } + if hit := p.classify(f.Text); hit.IsJailbreak { + return pipeline.Deny("jailbreak.detected", hit.Category) + } + } + } + return pipeline.Action{Type: pipeline.Continue} +} +``` + +One import (`contracts`), one iteration pattern, works across A2A + +MCP + Inference. A fourth protocol that implements `Fragments` is +picked up with no guardrail change. + +### When NOT to implement ContentSource + +`ContentSource` is for **content-oriented** checks: free-text scanning +that's the same logic regardless of protocol. It doesn't help +**structure-oriented** guardrails — e.g., "only the `url` parameter of +`fetch_url` must match the allowlist." Those guardrails are inherently +protocol-aware; they should import the parser's extension type and read +fields directly. The two patterns coexist cleanly. + +Binary protocols, control-plane RPCs (MCP `initialize` / `ping`, +tools/list), and identity-only auth messages have no inspectable text — +simply don't implement the interface. + ## Registering a plugin A plugin advertises itself to the pipeline builder through `RegisterPlugin` diff --git a/authbridge/docs/plugin-tutorial.md b/authbridge/docs/plugin-tutorial.md index f59c18372..1c4b9f46a 100644 --- a/authbridge/docs/plugin-tutorial.md +++ b/authbridge/docs/plugin-tutorial.md @@ -298,6 +298,41 @@ func TestScenario(t *testing.T) { } ``` +## Step 8 — Expose content to guardrails (parser plugins) + +If your plugin is a **parser** whose extension carries user-visible +text, implement [`contracts.ContentSource`](../authlib/contracts/content.go) +so guardrails can inspect it without importing your package. See +[`plugin-reference.md` "Exposing content to guardrails"](./plugin-reference.md#exposing-content-to-guardrails) +for the role vocabulary and the mapping table across in-tree parsers. + +Minimal example for an Inference-like parser whose extension stores +`Messages []struct{ Role, Content string }`: + +```go +import "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" + +// Compile-time assertion — catches interface drift at build time. +var _ contracts.ContentSource = (*MyExtension)(nil) + +func (e *MyExtension) Fragments() []contracts.Fragment { + out := make([]contracts.Fragment, 0, len(e.Messages)) + for _, m := range e.Messages { + if m.Content == "" { + continue + } + out = append(out, contracts.Fragment{Role: m.Role, Text: m.Content}) + } + return out +} +``` + +That's the whole addition. Guardrails call `pctx.ContentSources()`, +iterate, and see your messages alongside every other parser's output. + +Skip this step if your protocol is binary, control-plane only, or +otherwise carries no text a guardrail would scan. + ## Optional interfaces Beyond the four required methods, plugins may implement: From 71ac9707dd296e02b5988965ae3dc3fe1ab4a83c Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 10 May 2026 11:19:32 -0400 Subject: [PATCH 2/5] fixup(authlib): PR #393 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Hai Huang --- authbridge/authlib/contracts/content.go | 10 ++++++++ authbridge/authlib/pipeline/content.go | 33 +++++++++++++++++-------- authbridge/docs/plugin-tutorial.md | 8 ++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/authbridge/authlib/contracts/content.go b/authbridge/authlib/contracts/content.go index 2d70101c7..f875a5534 100644 --- a/authbridge/authlib/contracts/content.go +++ b/authbridge/authlib/contracts/content.go @@ -43,6 +43,16 @@ type ContentSource interface { // Fragment is one inspectable piece of text, tagged with the role that // produced it. +// +// The struct may grow additional fields in future versions (e.g., a +// Path locator for violation citations, or a Kind hint for +// format-aware scanners). Use **named-field initialization** when +// constructing literals — `Fragment{Role: ..., Text: ...}` rather +// than `Fragment{"user", "..."}` — so existing call sites and tests +// remain unaffected when fields are added. Tests that compare +// `[]Fragment` literals with reflect.DeepEqual should likewise rely +// on named fields so new zero-valued fields compare equal without +// test churn. type Fragment struct { // Role identifies who produced the text. Use the standard values // below when the semantic fit is clear; protocols that don't fit diff --git a/authbridge/authlib/pipeline/content.go b/authbridge/authlib/pipeline/content.go index 4f3fc2e25..f09113a61 100644 --- a/authbridge/authlib/pipeline/content.go +++ b/authbridge/authlib/pipeline/content.go @@ -2,6 +2,7 @@ package pipeline import ( "encoding/json" + "log/slog" "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" ) @@ -57,8 +58,12 @@ func (e *A2AExtension) Fragments() []contracts.Fragment { // normalizeA2ARole rewrites A2A's native role vocabulary to match the // Inference / OpenAI-style vocabulary. Keeping guardrails to a single -// role set across protocols is worth the small loss of A2A fidelity — -// callers that need the raw value read *A2AExtension.Role directly. +// role set across protocols is worth the small loss of A2A fidelity. +// A2A-aware consumers may type-assert to *A2AExtension to read the +// raw Role field directly; framework-generic guardrails consuming via +// the ContentSource interface treat the normalized value as +// authoritative (the interface deliberately does not surface the raw +// value). func normalizeA2ARole(r string) string { switch r { case "agent": @@ -136,7 +141,10 @@ func (e *InferenceExtension) Fragments() []contracts.Fragment { if e == nil { return nil } - out := make([]contracts.Fragment, 0, len(e.Messages)+1+2*len(e.ToolCalls)) + // Use a nil slice so an empty result returns nil, consistent with + // A2AExtension.Fragments and MCPExtension.Fragments — append + // tolerates nil and the cap hint isn't measurable on this path. + var out []contracts.Fragment for _, m := range e.Messages { if m.Content == "" { @@ -161,17 +169,20 @@ func (e *InferenceExtension) Fragments() []contracts.Fragment { } } - if len(out) == 0 { - return nil - } return out } // stringifyAny renders an arbitrary argument value as a string suitable -// for text scanning. Strings pass through unchanged; anything else -// goes through JSON so nested maps / slices become flat inspectable -// text. A marshal error (should be rare for JSON-origin data) yields -// empty string, which the caller filters. +// for text scanning. Strings pass through unchanged; anything else goes +// through JSON so nested maps / slices become flat inspectable text. +// +// Precondition: v should be JSON-origin data (values that came out of +// json.Unmarshal into map[string]any / []any / primitives). Those +// round-trip through json.Marshal without error in practice. Values +// with unmarshalable types (channels, funcs, cyclic refs) will hit the +// error path — the function returns "" and logs at DEBUG so the skip +// is observable in verbose runs rather than silent. Callers filter +// empty strings regardless. func stringifyAny(v any) string { if v == nil { return "" @@ -181,6 +192,8 @@ func stringifyAny(v any) string { } b, err := json.Marshal(v) if err != nil { + slog.Debug("pipeline/content: stringifyAny marshal error, returning empty", + "error", err) return "" } return string(b) diff --git a/authbridge/docs/plugin-tutorial.md b/authbridge/docs/plugin-tutorial.md index 1c4b9f46a..93dbba76e 100644 --- a/authbridge/docs/plugin-tutorial.md +++ b/authbridge/docs/plugin-tutorial.md @@ -330,6 +330,14 @@ func (e *MyExtension) Fragments() []contracts.Fragment { That's the whole addition. Guardrails call `pctx.ContentSources()`, iterate, and see your messages alongside every other parser's output. +**Role normalization.** The minimal example above emits `m.Role` as-is. +If your protocol uses role names that differ from the standard vocabulary +(e.g., A2A uses `"agent"` where the standard is `"assistant"`), remap +them to the `contracts.Role*` constants inside `Fragments` so guardrails +match uniformly across protocols. See +[`A2AExtension.Fragments`](../authlib/pipeline/content.go) for a reference +implementation that rewrites `"agent"` → `"assistant"`. + Skip this step if your protocol is binary, control-plane only, or otherwise carries no text a guardrail would scan. From 97c07f18b609fd2d5ffcbe80613619798e8869ed Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 10 May 2026 11:23:07 -0400 Subject: [PATCH 3/5] fixup(authlib): Correct normalizeA2ARole comment framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: Hai Huang --- authbridge/authlib/pipeline/content.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/authbridge/authlib/pipeline/content.go b/authbridge/authlib/pipeline/content.go index f09113a61..0428522a1 100644 --- a/authbridge/authlib/pipeline/content.go +++ b/authbridge/authlib/pipeline/content.go @@ -56,14 +56,21 @@ func (e *A2AExtension) Fragments() []contracts.Fragment { return out } -// normalizeA2ARole rewrites A2A's native role vocabulary to match the -// Inference / OpenAI-style vocabulary. Keeping guardrails to a single -// role set across protocols is worth the small loss of A2A fidelity. -// A2A-aware consumers may type-assert to *A2AExtension to read the -// raw Role field directly; framework-generic guardrails consuming via -// the ContentSource interface treat the normalized value as -// authoritative (the interface deliberately does not surface the raw -// value). +// normalizeA2ARole rewrites A2A's native role vocabulary (user/agent) +// to the standard cross-protocol vocabulary used by Inference and by +// the Role constants in authlib/contracts (user/assistant). Uniform +// role names across every protocol that implements ContentSource is +// the design goal: a jailbreak detector, PII scrubber, or content +// classifier compares `f.Role == contracts.RoleUser` once and works +// on A2A, MCP, and Inference without per-protocol branching. +// +// Fragment.Role IS the role information consumers need — no +// type-assertion required. The only situation where reading the raw +// A2A-native string ("agent") would be appropriate is A2A-specialized +// tooling (e.g., an A2A-protocol inspector that wants to display the +// wire-level value verbatim); such callers hold a concrete +// *A2AExtension and read .Role directly. Framework-generic consumers +// should not do that. func normalizeA2ARole(r string) string { switch r { case "agent": From 7a3c3f8fd90cdb71c558a6cbc4374fb5cd885a5b Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 10 May 2026 11:29:24 -0400 Subject: [PATCH 4/5] docs(pipeline): Note conceptual home of A2A/MCP/Inference Fragments 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) Signed-off-by: Hai Huang --- authbridge/authlib/pipeline/content.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/authbridge/authlib/pipeline/content.go b/authbridge/authlib/pipeline/content.go index 0428522a1..b0af369ac 100644 --- a/authbridge/authlib/pipeline/content.go +++ b/authbridge/authlib/pipeline/content.go @@ -71,6 +71,17 @@ func (e *A2AExtension) Fragments() []contracts.Fragment { // wire-level value verbatim); such callers hold a concrete // *A2AExtension and read .Role directly. Framework-generic consumers // should not do that. +// +// Conceptual home: this helper (and Fragments on A2AExtension) more +// naturally belongs in the a2a-parser package — it's A2A-specific +// logic that the framework itself has no stake in. It lives here +// because Go requires methods on a type to be declared in the type's +// own package, and A2AExtension lives in pipeline/ as a named slot +// on Extensions. A planned protocols-registry refactor will move the +// extension types into their parser packages (removing the named +// slots on Extensions); this helper moves with A2AExtension at that +// point. The equivalent move applies to MCPExtension.Fragments and +// InferenceExtension.Fragments below. func normalizeA2ARole(r string) string { switch r { case "agent": From a2e331b11bd469d10eb8ace52701690ba67e467e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 10 May 2026 13:13:20 -0400 Subject: [PATCH 5/5] fixup(authlib): Surface malformed MCP payloads; scan error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #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) Signed-off-by: Hai Huang --- authbridge/authlib/pipeline/content.go | 69 ++++++++++++++++----- authbridge/authlib/pipeline/content_test.go | 61 ++++++++++++++++++ authbridge/docs/plugin-reference.md | 2 +- 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/authbridge/authlib/pipeline/content.go b/authbridge/authlib/pipeline/content.go index b0af369ac..7e8b8165f 100644 --- a/authbridge/authlib/pipeline/content.go +++ b/authbridge/authlib/pipeline/content.go @@ -2,6 +2,7 @@ package pipeline import ( "encoding/json" + "fmt" "log/slog" "github.com/kagenti/kagenti-extensions/authbridge/authlib/contracts" @@ -101,12 +102,21 @@ func normalizeA2ARole(r string) string { // carrying user-intent content. Control-plane calls (initialize, ping, // tools/list, resources/list, etc.) return nil. The tool name is // emitted as role=tool; each argument value is emitted as -// role=tool_args, JSON-stringified if non-string. +// role=tool_args, JSON-stringified if non-string. On JSON-RPC errors, +// the error message is emitted as role=tool_result so guardrails see +// content that could leak through the error channel (credentials, +// stack traces, PII) the same as they see normal tool output. // // Response-phase: MCP tool results are conventionally shaped as // {"content": [{"type":"text","text":"..."}, {"type":"image",...}, ...]}. // Text items are emitted with role=tool_result; non-text items are // skipped as not inspectable. +// +// Type-assertion misses on Params["arguments"] / Result["content"] / +// content-items get a DEBUG log. These shapes are what the MCP parser +// produced from a JSON-validated body, so misses typically indicate +// a malformed or protocol-non-conforming payload worth surfacing to +// operators debugging an odd client — rather than a silent skip. func (e *MCPExtension) Fragments() []contracts.Fragment { if e == nil { return nil @@ -117,32 +127,57 @@ func (e *MCPExtension) Fragments() []contracts.Fragment { if name, _ := e.Params["name"].(string); name != "" { out = append(out, contracts.Fragment{Role: contracts.RoleTool, Text: name}) } - if args, ok := e.Params["arguments"].(map[string]any); ok { - for _, v := range args { - text := stringifyAny(v) - if text != "" { - out = append(out, contracts.Fragment{Role: contracts.RoleToolArgs, Text: text}) + if raw, present := e.Params["arguments"]; present { + args, ok := raw.(map[string]any) + if !ok { + slog.Debug("pipeline/content: MCP tools/call arguments not a map; skipping", + "type", fmt.Sprintf("%T", raw)) + } else { + for _, v := range args { + text := stringifyAny(v) + if text != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleToolArgs, Text: text}) + } } } } } + // Tool result content, when present. Errors that arrive as the + // JSON-RPC-level Err field (not content[]) are handled below. if e.Result != nil { - if items, ok := e.Result["content"].([]any); ok { - for _, it := range items { - m, ok := it.(map[string]any) - if !ok { - continue - } - if m["type"] != "text" { - continue - } - if t, _ := m["text"].(string); t != "" { - out = append(out, contracts.Fragment{Role: contracts.RoleToolResult, Text: t}) + if raw, present := e.Result["content"]; present { + items, ok := raw.([]any) + if !ok { + slog.Debug("pipeline/content: MCP result.content not an array; skipping", + "type", fmt.Sprintf("%T", raw)) + } else { + for i, it := range items { + m, ok := it.(map[string]any) + if !ok { + slog.Debug("pipeline/content: MCP result.content item not an object; skipping", + "index", i, "type", fmt.Sprintf("%T", it)) + continue + } + if m["type"] != "text" { + continue + } + if t, _ := m["text"].(string); t != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleToolResult, Text: t}) + } } } } } + + // JSON-RPC-level errors carry a Message that may contain + // inspectable text (leaked credentials, stack traces, PII from + // failed DB lookups, etc.). Emit as tool_result so a PII scrubber + // or credential detector covers the error channel uniformly. + if e.Err != nil && e.Err.Message != "" { + out = append(out, contracts.Fragment{Role: contracts.RoleToolResult, Text: e.Err.Message}) + } + return out } diff --git a/authbridge/authlib/pipeline/content_test.go b/authbridge/authlib/pipeline/content_test.go index af3b308fd..3d0f19d28 100644 --- a/authbridge/authlib/pipeline/content_test.go +++ b/authbridge/authlib/pipeline/content_test.go @@ -206,6 +206,67 @@ func TestMCPExtension_Fragments(t *testing.T) { {Role: contracts.RoleToolResult, Text: "caption"}, }, }, + { + name: "error_message_emitted_as_tool_result", + ext: &MCPExtension{ + Method: "tools/call", + Params: map[string]any{"name": "fetch_url"}, + Err: &MCPError{Code: -32602, Message: "invalid url: http://internal.example/secret"}, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleTool, Text: "fetch_url"}, + {Role: contracts.RoleToolResult, Text: "invalid url: http://internal.example/secret"}, + }, + }, + { + name: "error_and_result_content_both_emitted", + ext: &MCPExtension{ + Method: "tools/call", + Result: map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "partial output"}, + }, + }, + Err: &MCPError{Code: 1, Message: "timeout"}, + }, + want: []contracts.Fragment{ + {Role: contracts.RoleToolResult, Text: "partial output"}, + {Role: contracts.RoleToolResult, Text: "timeout"}, + }, + }, + { + name: "error_empty_message_not_emitted", + ext: &MCPExtension{ + Method: "tools/call", + Err: &MCPError{Code: 1, Message: ""}, + }, + want: nil, + }, + { + name: "arguments_not_a_map_skipped", + ext: &MCPExtension{ + Method: "tools/call", + Params: map[string]any{ + "name": "fetch_url", + "arguments": "malformed-string-not-object", + }, + }, + // tool name still emitted; arguments skipped with DEBUG log + want: []contracts.Fragment{ + {Role: contracts.RoleTool, Text: "fetch_url"}, + }, + }, + { + name: "result_content_not_an_array_skipped", + ext: &MCPExtension{ + Method: "tools/call", + Result: map[string]any{ + "content": "malformed-string-not-array", + }, + }, + // skipped with DEBUG log; no fragments emitted + want: nil, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md index a87c565f0..9a773ef86 100644 --- a/authbridge/docs/plugin-reference.md +++ b/authbridge/docs/plugin-reference.md @@ -549,7 +549,7 @@ their own policy. | `system` | — | — | system messages | | `tool` | tools/call name | — | model's tool call name | | `tool_args` | tools/call argument values | — | model's tool call arguments | -| `tool_result` | tools/call result text | — | conversation's prior tool messages | +| `tool_result` | tools/call result text **and** JSON-RPC error messages | — | conversation's prior tool messages | Empty cells are intentional. A2A has no system-prompt concept; MCP has no user-message concept. Guardrails ignore roles they don't care about; no