Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions authbridge/authlib/pipeline/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,20 @@ type MCPPromptMetadata struct {
Args map[string]string
}

// A2AExtension carries parsed A2A protocol metadata.
// A2AExtension carries parsed A2A protocol metadata from inbound requests.
type A2AExtension struct {
TaskID string
Method string // "tasks/send", "tasks/get", etc.
Parts []A2APart
Method string // JSON-RPC method: "message/send", "message/stream"
RPCID any // JSON-RPC id for request-response correlation
SessionID string // conversation session (from params.sessionId)
MessageID string // unique message ID (from params.message.messageId)
Role string // "user" or "assistant"
Parts []A2APart
}

// A2APart represents a message part in an A2A request.
type A2APart struct {
Type string // "text", "file", "data"
Content string
Kind string // "text", "file", "data"
Content string // text content, file URI, or serialized data
}

// SecurityExtension carries guardrail output.
Expand Down
107 changes: 107 additions & 0 deletions authbridge/authlib/plugins/a2aparser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package plugins

import (
"context"
"encoding/json"
"log/slog"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
)

// A2AParser parses A2A JSON-RPC 2.0 request bodies and populates
// pctx.Extensions.A2A with the parsed method, session ID, message parts,
// and role for downstream policy plugins (e.g., guardrails).
type A2AParser struct{}

func NewA2AParser() *A2AParser { return &A2AParser{} }

func (p *A2AParser) Name() string { return "a2a-parser" }

func (p *A2AParser) Capabilities() pipeline.PluginCapabilities {
return pipeline.PluginCapabilities{
Writes: []string{"a2a"},
BodyAccess: true,
}
}

func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
if len(pctx.Body) == 0 {
slog.Debug("a2a-parser: no body, skipping")
return pipeline.Action{Type: pipeline.Continue}
}

var rpc jsonRPCRequest
if err := json.Unmarshal(pctx.Body, &rpc); err != nil {
slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body))
return pipeline.Action{Type: pipeline.Continue}
}

ext := &pipeline.A2AExtension{
Method: rpc.Method,
RPCID: rpc.ID,
}

// Extract message fields generically — any method with params.message
// gets full extraction (forward-compatible with future A2A methods).
ext.SessionID = rpc.stringParam("sessionId")
if msg := rpc.mapParam("message"); msg != nil {
if role, ok := msg["role"].(string); ok {
ext.Role = role
}
if messageID, ok := msg["messageId"].(string); ok {
ext.MessageID = messageID
}
if rawParts, ok := msg["parts"].([]any); ok {
ext.Parts = parseA2AParts(rawParts)
}
}

pctx.Extensions.A2A = ext

slog.Info("a2a-parser", "method", rpc.Method)
slog.Debug("a2a-parser: extracted",
"method", rpc.Method,
"sessionId", ext.SessionID,
"role", ext.Role,
"messageId", ext.MessageID,
"parts", len(ext.Parts),
)
return pipeline.Action{Type: pipeline.Continue}
}

func (p *A2AParser) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action {
return pipeline.Action{Type: pipeline.Continue}
}

func parseA2AParts(rawParts []any) []pipeline.A2APart {
parts := make([]pipeline.A2APart, 0, len(rawParts))
for _, raw := range rawParts {
partMap, ok := raw.(map[string]any)
if !ok {
continue
}
kind, ok := partMap["kind"].(string)
if !ok || kind == "" {
continue
}
var content string
switch kind {
case "text":
content, _ = partMap["text"].(string)
case "file":
// TODO: update when A2A spec stabilizes — canonical Part uses mediaType + content field presence, not "kind".
content, _ = partMap["data"].(string)
if content == "" {
content, _ = partMap["uri"].(string)
Comment thread
huang195 marked this conversation as resolved.
}
Comment on lines +91 to +96

@evaline-ju evaline-ju May 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref https://a2a-protocol.org/latest/definitions/#protobuf I'm curious how this case maps. Does another part of Kagenti form file data?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Looking at the A2A spec, the current spec (June 2025 draft) uses a unified Part with oneof content: text, raw (base64 bytes), url (file pointer), or data (structured JSON), plus optional mediaType/filename metadata.

The older SDK samples (which Kagenti UI currently uses) still send {"kind": "file", "data": "..."} or {"kind": "file", "uri": "..."} — that is what this code handles today. No part of Kagenti currently creates file parts; this branch just ensures we do not lose content if a future A2A client sends one.

As the spec stabilizes, we should update this to match the canonical Part structure (no kind discriminator, use mediaType + content field presence instead). I will add a TODO comment noting this.

case "data":
if dataVal, ok := partMap["data"]; ok && dataVal != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This logs at Info level on every successfully parsed request. Since the parser runs on all inbound traffic (not just A2A), this could be noisy in production. Consider slog.Debug here and keeping only the existing Debug log below for detailed fields. The MCP parser uses slog.Info too, so this is consistent — but both might want to be Debug in the long run.

if b, err := json.Marshal(dataVal); err == nil {
content = string(b)
}
}
}
parts = append(parts, pipeline.A2APart{Kind: kind, Content: content})
}
return parts
}
Loading
Loading