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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ See `convert/testdata/*/input.yaml` for worked examples.
|---|---|
| Model | One **route per (provider endpoint, capability)** under a single shared `ai-gateway` Service, with the path derived from the model's `formats[0].type` (llm_format) + capability via the endpoint table. Each route gets an `ai-proxy-advanced` plugin (`route:` FK) — models that resolve to the same endpoint share one route, contributing one `targets[]` entry each. Body-model routes also get an `ai-model-selector` plugin. One `ai-models` entry (`name` + `alias`) is emitted per model. |
| Provider | Not a standalone entity. Its `type` and `config.auth` populate each referencing target's `model.provider`, `model.options`, and `auth`. |
| MCP Server | Service + Route + `ai-mcp-proxy` (`config.mode` = source type). Server ACLs / per-tool ACLs are written into the plugin config (`default_acl`, `tools[].acl`), not Kong `acl` plugins. `access.identity_providers` + `access.metadata` (openid-connect) add an `ai-mcp-oauth2` plugin and append `metadata.endpoint` to the route (listener / conversion-listener / passthrough-listener only); a `key-auth` provider adds a `key-auth` plugin (and is rejected if `metadata` is set). |
| Agent (`a2a`) | Service (`config.url`) + Route + `ai-a2a-proxy` plugin (logging). |
| MCP Server | Service + Route + `ai-mcp-proxy` (`config.mode` = source type). Server ACLs / per-tool ACLs are written into the plugin config (`default_acl`, `tools[].acl`), not Kong `acl` plugins. `access.identity_providers` + `access.metadata` (openid-connect) add an `ai-mcp-oauth2` plugin and append `metadata.endpoint` to the route (listener / conversion-listener / passthrough-listener only); a `key-auth` provider adds a `key-auth` plugin (and is rejected if `metadata` is set). `config.upstream.auth` (AWS SigV4) lowers to the plugin's `auth` record. |
| Agent (`a2a`) | Service (`config.url`) + Route + `ai-a2a-proxy` plugin (logging). `config.upstream.auth` (AWS SigV4) lowers to the plugin's `auth` record, and `config.proxy` to `proxy_config`. |
| Agent (`http`) | Service (`config.url`) + Route, no AI plugin. |
| Policy | Kong plugin (`name` = policy `type`, config passed through). `global: true` -> one top-level plugin; otherwise instantiated per referencing entity. |
| Consumer | Consumer (`username` = name, `custom_id`), `groups` membership, nested `keyauth_credentials`, scoped policy plugins. |
Expand Down Expand Up @@ -171,6 +171,11 @@ Lossy by design (the forward direction never emits them): `display_name`,
credential types are warned about and skipped.
- **MCP upstream.** Passthrough MCP servers without an `upstream_url` get a
placeholder host and a warning.
- **Upstream auth.** Agents and MCP Servers carry `config.upstream.auth` (AWS
SigV4, `type: aws`), which maps to the `ai-a2a-proxy` / `ai-mcp-proxy` `auth`
record (`provider: aws_iam`, nested `aws_iam` options). Unsupported auth types
are warned about and dropped. The plugin's `aws_iam.bearer_token` has no AI
Gateway representation, so the reverse direction warns and drops it.
- **MCP OAuth2.** MCP `access.identity_providers` / `access.metadata` round-trips
in both directions. An openid-connect provider lowers its client credentials
plus the identically-typed / unambiguous fields onto the `ai-mcp-oauth2`
Expand Down
26 changes: 22 additions & 4 deletions convert/agent.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package convert

import (
"fmt"

"github.com/Kong/ai-deck-converter/internal/aigw"
"github.com/Kong/ai-deck-converter/internal/aimap"
"github.com/Kong/ai-deck-converter/internal/kong"
Expand Down Expand Up @@ -33,8 +35,14 @@ func (c *Converter) convertAgents() error {

switch a.Type {
case "a2a":
plugin := a2aPlugin(a)
plugin.Source = source("agent", a.Name, "config")
plugin, err := c.a2aPlugin(a)
if err != nil {
return err
}
plugin.Source = source("agent", a.Name, "config",
kong.FieldMapping{GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"},
kong.FieldMapping{GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"},
)
route.Plugins = append(route.Plugins, plugin)
case "http":
// plain HTTP proxy: Service + Route only
Expand Down Expand Up @@ -67,7 +75,7 @@ func (c *Converter) convertAgents() error {
return nil
}

func a2aPlugin(a *aigw.Agent) kong.Plugin {
func (c *Converter) a2aPlugin(a *aigw.Agent) (kong.Plugin, error) {
cfg := map[string]any{}
if logging := loggingBlock(withLoggingDefaults(a.Config.Logging, false, true)); logging != nil {
// log_audits is an ai-mcp-proxy field; the ai-a2a-proxy schema has no
Expand All @@ -80,7 +88,17 @@ func a2aPlugin(a *aigw.Agent) kong.Plugin {
if a.Config.MaxRequestBodySize != nil {
cfg["max_request_body_size"] = *a.Config.MaxRequestBodySize
}
return kong.Plugin{Name: "ai-a2a-proxy", Config: cfg}
if pc := proxyConfigBlock(a.Config.Proxy); pc != nil {
cfg["proxy_config"] = pc
}
auth, err := c.upstreamAuthBlock(a.Config.Upstream, fmt.Sprintf("agent %q", a.Name))
if err != nil {
return kong.Plugin{}, err
}
if auth != nil {
cfg["auth"] = auth
}
return kong.Plugin{Name: "ai-a2a-proxy", Config: cfg}, nil
}

// withLoggingDefaults returns a copy of l with statistics/payloads defaulted
Expand Down
11 changes: 9 additions & 2 deletions convert/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ mcp_servers:
{GeneratedPrefix: "config.mode", SourcePrefix: "type"},
{GeneratedPrefix: "config.tools", SourcePrefix: "tools"},
{GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"},
{GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"},
{GeneratedPrefix: "config.default_acl", SourcePrefix: "access"},
{GeneratedPrefix: "config.acl_attribute_type", SourcePrefix: "access.acl_attribute_type"},
{GeneratedPrefix: "config.access_token_claim_field", SourcePrefix: "access.access_token_claim_field"},
Expand Down Expand Up @@ -146,6 +147,10 @@ agents:
EntityType: "agent",
EntityName: "booking-agent",
FieldPrefix: "config",
FieldMappings: []FieldMapping{
{GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"},
{GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"},
},
}}, metadata.Plugins)
}

Expand Down Expand Up @@ -936,8 +941,10 @@ func TestA2APluginDropsLogAudits(t *testing.T) {
},
},
}
logging, ok := a2aPlugin(a).Config["logging"].(map[string]any)
require.True(t, ok, "expected logging block, got %v", a2aPlugin(a).Config["logging"])
plugin, err := (&Converter{}).a2aPlugin(a)
require.NoError(t, err)
logging, ok := plugin.Config["logging"].(map[string]any)
require.True(t, ok, "expected logging block, got %v", plugin.Config["logging"])
require.NotContains(t, logging, "log_audits", "ai-a2a-proxy must not emit log_audits, got %v", logging)
require.Equal(t, true, logging["log_statistics"], "expected log_statistics true")
}
Expand Down
12 changes: 12 additions & 0 deletions convert/mcp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package convert

import (
"fmt"

"github.com/Kong/ai-deck-converter/internal/aigw"
"github.com/Kong/ai-deck-converter/internal/kong"
)
Expand All @@ -24,6 +26,7 @@ func (c *Converter) convertMCPServers() error {
kong.FieldMapping{GeneratedPrefix: "config.mode", SourcePrefix: "type"},
kong.FieldMapping{GeneratedPrefix: "config.tools", SourcePrefix: "tools"},
kong.FieldMapping{GeneratedPrefix: "config.proxy_config", SourcePrefix: "config.proxy"},
kong.FieldMapping{GeneratedPrefix: "config.auth", SourcePrefix: "config.upstream.auth"},
kong.FieldMapping{GeneratedPrefix: "config.default_acl", SourcePrefix: "access"},
kong.FieldMapping{GeneratedPrefix: "config.acl_attribute_type", SourcePrefix: "access.acl_attribute_type"},
kong.FieldMapping{
Expand Down Expand Up @@ -99,6 +102,15 @@ func (c *Converter) mcpPlugin(m *aigw.MCPServer) (kong.Plugin, error) {
if pc := proxyConfigBlock(m.Config.Proxy); pc != nil {
cfg["proxy_config"] = pc
}
// Upstream authentication (e.g. AWS SigV4) lowers to the plugin's auth
// record; only emitted when set.
auth, err := c.upstreamAuthBlock(m.Config.Upstream, fmt.Sprintf("MCP server %q", m.Name))
if err != nil {
return kong.Plugin{}, err
}
if auth != nil {
cfg["auth"] = auth
}
// tools_cache_ttl_seconds is required by the plugin in upstream-server mode.
if m.Config.ToolsCacheTTLSeconds != nil {
cfg["tools_cache_ttl_seconds"] = *m.Config.ToolsCacheTTLSeconds
Expand Down
30 changes: 30 additions & 0 deletions convert/testdata/40_agent_upstream_auth_aws/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
_format_version: "3.0"
services:
- name: booking-agent
url: https://booking-agent.internal:8443/a2a
routes:
- name: booking-route
paths:
- /agents/booking
methods:
- POST
plugins:
- name: ai-a2a-proxy
config:
auth:
aws_iam:
assume_role_arn: arn:aws:iam::123456789012:role/agentcore
aws_access_key_id: AKIAIOSFODNN7EXAMPLE
aws_region: us-east-1
aws_secret_access_key: '{vault://env/aws-secret}'
aws_session_token: '{vault://env/aws-session}'
role_session_name: kong-a2a
sts_endpoint_url: https://sts.us-east-1.amazonaws.com
provider: aws_iam
logging:
log_payloads: false
log_statistics: true
max_payload_size: 1048576
proxy_config:
https_proxy_host: proxy.internal
https_proxy_port: 8080
21 changes: 21 additions & 0 deletions convert/testdata/40_agent_upstream_auth_aws/input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
agents:
# a2a agent with AWS SigV4 upstream auth (e.g. an AgentCore agent) and an
# outbound HTTPS proxy.
- type: a2a
display_name: Booking Agent
name: booking-agent
config:
url: https://booking-agent.internal:8443/a2a
route: {name: booking-route, paths: [/agents/booking], methods: [POST]}
upstream:
auth:
type: aws
region: us-east-1
access_key_id: AKIAIOSFODNN7EXAMPLE
secret_access_key: "{vault://env/aws-secret}"
session_token: "{vault://env/aws-session}"
assume_role_arn: arn:aws:iam::123456789012:role/agentcore
role_session_name: kong-a2a
sts_endpoint_url: https://sts.us-east-1.amazonaws.com
proxy:
https_proxy: {host: proxy.internal, port: 8080}
26 changes: 26 additions & 0 deletions convert/testdata/41_mcp_upstream_auth_aws/expected.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
_format_version: "3.0"
services:
- name: secure-vendor-mcp
url: https://mcp.internal.kongair.com
routes:
- name: secure-vendor-mcp-route
paths:
- /mcp/secure-vendor
plugins:
- name: ai-mcp-proxy
config:
auth:
aws_iam:
aws_access_key_id: AKIAIOSFODNN7EXAMPLE
aws_region: us-west-2
aws_secret_access_key: '{vault://env/aws-secret}'
provider: aws_iam
include_consumer_groups: true
logging:
log_audits: false
log_payloads: false
log_statistics: true
mode: passthrough-listener
tools:
- description: A vendor tool
name: vendorTool
18 changes: 18 additions & 0 deletions convert/testdata/41_mcp_upstream_auth_aws/input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
mcp_servers:
# passthrough MCP server fronting an AWS-signed upstream (AWS SigV4 upstream
# auth).
- type: passthrough-listener
display_name: Secure Vendor MCP
name: secure-vendor-mcp
upstream_url: https://mcp.internal.kongair.com
config:
route: {paths: [/mcp/secure-vendor]}
upstream:
auth:
type: aws
region: us-west-2
access_key_id: AKIAIOSFODNN7EXAMPLE
secret_access_key: "{vault://env/aws-secret}"
tools:
- name: vendorTool
description: A vendor tool
45 changes: 45 additions & 0 deletions convert/upstream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package convert

import (
"github.com/Kong/ai-deck-converter/internal/aigw"
"github.com/Kong/ai-deck-converter/internal/aimap"
)

// upstreamAuthBlock lowers config.upstream into the `auth` record shared by the
// ai-a2a-proxy and ai-mcp-proxy plugins. It returns nil when no upstream auth
// is set, so the plugin's `provider: off` default stays implicit and the output
// remains minimal. Reversed by revert's upstreamFromConfig.
//
// entity is used only for warning messages (e.g. `agent "foo"`).
func (c *Converter) upstreamAuthBlock(u *aigw.UpstreamConfig, entity string) (map[string]any, error) {
if u == nil || u.Auth == nil {
return nil, nil
}
a := u.Auth
if a.Type != aimap.UpstreamAuthTypeAWS {
return nil, c.warn("%s: unsupported upstream auth type %q; only %q is supported",
entity, a.Type, aimap.UpstreamAuthTypeAWS)
}

// Map the AI Gateway field names to their plugin aws_iam counterparts via
// the shared aimap table so forward and reverse can't drift.
values := map[string]string{
"access_key_id": a.AccessKeyID,
"secret_access_key": a.SecretAccessKey,
"session_token": a.SessionToken,
"region": a.Region,
"assume_role_arn": a.AssumeRoleARN,
"role_session_name": a.RoleSessionName,
"sts_endpoint_url": a.STSEndpointURL,
}
awsIAM := map[string]any{}
for _, k := range aimap.AWSIAMAuthKeys {
setIfNotEmpty(awsIAM, k.Plugin, values[k.AIGW])
}

auth := map[string]any{"provider": aimap.UpstreamAuthProviderAWSIAM}
if len(awsIAM) > 0 {
auth["aws_iam"] = awsIAM
}
return auth, nil
}
34 changes: 34 additions & 0 deletions convert/upstream_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package convert

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

// An unsupported upstream auth type warns (non-strict) and drops the auth
// block, and is fatal under -strict.
func TestConvertUpstreamAuthUnsupportedType(t *testing.T) {
src := []byte(`
agents:
- type: a2a
name: booking-agent
config:
url: https://booking-agent.internal/a2a
route: {paths: [/agents/booking]}
upstream:
auth:
type: gcp
region: us-east-1
`)

out, warnings, err := Convert(src, Options{})
require.NoError(t, err)
require.NotEmpty(t, warnings)
require.Contains(t, strings.Join(warnings, "\n"), "unsupported upstream auth type")
require.NotContains(t, string(out), "auth:", "unsupported auth must not be emitted")

_, _, err = Convert(src, Options{Strict: true})
require.Error(t, err, "unsupported upstream auth type must be fatal in strict mode")
}
8 changes: 7 additions & 1 deletion internal/aigw/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ type AgentAccessConfig struct {
IdentityProviders []string `yaml:"identity_providers,omitempty"`
}

// AgentConfig holds the upstream URL, route, and logging configuration.
// AgentConfig holds the upstream URL, route, logging, upstream-auth, and proxy
// configuration.
type AgentConfig struct {
URL string `yaml:"url,omitempty"`
Route RouteConfig `yaml:"route,omitempty"`
MaxRequestBodySize *int `yaml:"max_request_body_size,omitempty"`
Logging *Logging `yaml:"logging,omitempty"`
// Upstream lowers to the ai-a2a-proxy plugin's auth record (upstream
// authentication, e.g. AWS SigV4).
Upstream *UpstreamConfig `yaml:"upstream,omitempty"`
// Proxy lowers to the ai-a2a-proxy plugin's proxy_config.
Proxy *ProxyConfig `yaml:"proxy,omitempty"`
}
3 changes: 3 additions & 0 deletions internal/aigw/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ type MCPServerConfig struct {
// Proxy lowers to the ai-mcp-proxy plugin's proxy_config (only honored by
// the plugin in passthrough-listener mode).
Proxy *ProxyConfig `yaml:"proxy,omitempty"`
// Upstream lowers to the ai-mcp-proxy plugin's auth record (upstream
// authentication, e.g. AWS SigV4).
Upstream *UpstreamConfig `yaml:"upstream,omitempty"`
// ToolsCacheTTLSeconds maps to the ai-mcp-proxy plugin's
// tools_cache_ttl_seconds (required by the plugin in upstream-server mode).
ToolsCacheTTLSeconds *int `yaml:"tools_cache_ttl_seconds,omitempty"`
Expand Down
23 changes: 23 additions & 0 deletions internal/aigw/upstream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package aigw

// UpstreamConfig mirrors the AIGatewayUpstreamConfig schema: configuration
// applied when proxying to the upstream service, carried by Agents
// (config.upstream) and MCP Servers (config.upstream). It lowers to the `auth`
// record shared by the ai-a2a-proxy and ai-mcp-proxy plugins.
type UpstreamConfig struct {
Auth *UpstreamAuth `yaml:"auth,omitempty"`
}

// UpstreamAuth is the authentication used when proxying to the upstream. It is
// a flat union discriminated by Type (currently only "aws" / AWS SigV4),
// matching the flat-union precedent used by ProviderAuth in provider.go.
type UpstreamAuth struct {
Type string `yaml:"type,omitempty"` // "aws"
AccessKeyID string `yaml:"access_key_id,omitempty"`
SecretAccessKey string `yaml:"secret_access_key,omitempty"`
SessionToken string `yaml:"session_token,omitempty"`
Region string `yaml:"region,omitempty"`
AssumeRoleARN string `yaml:"assume_role_arn,omitempty"`
RoleSessionName string `yaml:"role_session_name,omitempty"`
STSEndpointURL string `yaml:"sts_endpoint_url,omitempty"`
}
Loading