Skip to content

Commit bceb2c3

Browse files
committed
added passthrough headers - a2a agent (add/edit)
Signed-off-by: Satya <[email protected]>
1 parent 24c0432 commit bceb2c3

File tree

9 files changed

+237
-16
lines changed

9 files changed

+237
-16
lines changed

alembic/README

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Generic single-database configuration.

alembic/env.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from logging.config import fileConfig
2+
3+
from sqlalchemy import engine_from_config
4+
from sqlalchemy import pool
5+
6+
from alembic import context
7+
8+
# this is the Alembic Config object, which provides
9+
# access to the values within the .ini file in use.
10+
config = context.config
11+
12+
# Interpret the config file for Python logging.
13+
# This line sets up loggers basically.
14+
if config.config_file_name is not None:
15+
fileConfig(config.config_file_name)
16+
17+
# add your model's MetaData object here
18+
# for 'autogenerate' support
19+
# from myapp import mymodel
20+
# target_metadata = mymodel.Base.metadata
21+
target_metadata = None
22+
23+
# other values from the config, defined by the needs of env.py,
24+
# can be acquired:
25+
# my_important_option = config.get_main_option("my_important_option")
26+
# ... etc.
27+
28+
29+
def run_migrations_offline() -> None:
30+
"""Run migrations in 'offline' mode.
31+
32+
This configures the context with just a URL
33+
and not an Engine, though an Engine is acceptable
34+
here as well. By skipping the Engine creation
35+
we don't even need a DBAPI to be available.
36+
37+
Calls to context.execute() here emit the given string to the
38+
script output.
39+
40+
"""
41+
url = config.get_main_option("sqlalchemy.url")
42+
context.configure(
43+
url=url,
44+
target_metadata=target_metadata,
45+
literal_binds=True,
46+
dialect_opts={"paramstyle": "named"},
47+
)
48+
49+
with context.begin_transaction():
50+
context.run_migrations()
51+
52+
53+
def run_migrations_online() -> None:
54+
"""Run migrations in 'online' mode.
55+
56+
In this scenario we need to create an Engine
57+
and associate a connection with the context.
58+
59+
"""
60+
connectable = engine_from_config(
61+
config.get_section(config.config_ini_section, {}),
62+
prefix="sqlalchemy.",
63+
poolclass=pool.NullPool,
64+
)
65+
66+
with connectable.connect() as connection:
67+
context.configure(
68+
connection=connection, target_metadata=target_metadata
69+
)
70+
71+
with context.begin_transaction():
72+
context.run_migrations()
73+
74+
75+
if context.is_offline_mode():
76+
run_migrations_offline()
77+
else:
78+
run_migrations_online()

alembic/script.py.mako

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
${imports if imports else ""}
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = ${repr(up_revision)}
16+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
${upgrades if upgrades else "pass"}
24+
25+
26+
def downgrade() -> None:
27+
"""Downgrade schema."""
28+
${downgrades if downgrades else "pass"}

mcpgateway/admin.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9444,8 +9444,15 @@ async def admin_add_a2a_agent(
94449444
LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}")
94459445
LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}")
94469446

9447-
# TODO
9448-
# Handle passthrough_headers
9447+
passthrough_headers = str(form.get("passthrough_headers"))
9448+
if passthrough_headers and passthrough_headers.strip():
9449+
try:
9450+
passthrough_headers = json.loads(passthrough_headers)
9451+
except (json.JSONDecodeError, ValueError):
9452+
# Fallback to comma-separated parsing
9453+
passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
9454+
else:
9455+
passthrough_headers = None
94499456

94509457
# Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth"
94519458
auth_type_from_form = str(form.get("auth_type", ""))
@@ -9474,6 +9481,7 @@ async def admin_add_a2a_agent(
94749481
visibility=form.get("visibility", "private"),
94759482
team_id=team_id,
94769483
owner_email=user_email,
9484+
passthrough_headers=passthrough_headers,
94779485
)
94789486

94799487
LOGGER.info(f"Creating A2A agent: {agent_data.name} at {agent_data.endpoint_url}")
@@ -9551,6 +9559,7 @@ async def admin_edit_a2a_agent(
95519559
- team_id (optional)
95529560
- capabilities (JSON, optional)
95539561
- config (JSON, optional)
9562+
- passthrough_headers: Optional[List[str]]
95549563
95559564
Args:
95569565
agent_id (str): The ID of the agent being edited.
@@ -9678,17 +9687,16 @@ async def admin_edit_a2a_agent(
96789687
except (json.JSONDecodeError, ValueError):
96799688
auth_headers = []
96809689

9681-
"""
96829690
# Passthrough headers
9683-
passthrough_headers = None
9684-
if form.get("passthrough_headers"):
9685-
raw = str(form.get("passthrough_headers"))
9691+
passthrough_headers = str(form.get("passthrough_headers"))
9692+
if passthrough_headers and passthrough_headers.strip():
96869693
try:
9687-
passthrough_headers = json.loads(raw)
9688-
except (ValueError, json.JSONDecodeError):
9689-
passthrough_headers = [h.strip() for h in raw.split(",") if h.strip()]
9690-
9691-
"""
9694+
passthrough_headers = json.loads(passthrough_headers)
9695+
except (json.JSONDecodeError, ValueError):
9696+
# Fallback to comma-separated parsing
9697+
passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()]
9698+
else:
9699+
passthrough_headers = None
96929700

96939701
# Parse OAuth configuration - support both JSON string and individual form fields
96949702
oauth_config_json = str(form.get("oauth_config"))
@@ -9778,6 +9786,7 @@ async def admin_edit_a2a_agent(
97789786
auth_header_value=str(form.get("auth_header_value", "")),
97799787
auth_value=str(form.get("auth_value", "")),
97809788
auth_headers=auth_headers if auth_headers else None,
9789+
passthrough_headers=passthrough_headers,
97819790
oauth_config=oauth_config,
97829791
visibility=visibility,
97839792
team_id=team_id,

mcpgateway/db.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2571,6 +2571,9 @@ class A2AAgent(Base):
25712571
# OAuth configuration
25722572
oauth_config: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True, comment="OAuth 2.0 configuration including grant_type, client_id, encrypted client_secret, URLs, and scopes")
25732573

2574+
# Header passthrough configuration
2575+
passthrough_headers: Mapped[Optional[List[str]]] = mapped_column(JSON, nullable=True) # Store list of strings as JSON array
2576+
25742577
# Status and metadata
25752578
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
25762579
reachable: Mapped[bool] = mapped_column(Boolean, default=True)

mcpgateway/schemas.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3942,6 +3942,7 @@ class A2AAgentCreate(BaseModel):
39423942
protocol_version: str = Field(default="1.0", description="A2A protocol version supported")
39433943
capabilities: Dict[str, Any] = Field(default_factory=dict, description="Agent capabilities and features")
39443944
config: Dict[str, Any] = Field(default_factory=dict, description="Agent-specific configuration parameters")
3945+
passthrough_headers: Optional[List[str]] = Field(default=None, description="List of headers allowed to be passed through from client to target")
39453946
# Authorizations
39463947
auth_type: Optional[str] = Field(None, description="Type of authentication: basic, bearer, headers, oauth, or none")
39473948
# Fields for various types of authentication
@@ -4222,6 +4223,7 @@ class A2AAgentUpdate(BaseModelWithConfigDict):
42224223
protocol_version: Optional[str] = Field(None, description="A2A protocol version supported")
42234224
capabilities: Optional[Dict[str, Any]] = Field(None, description="Agent capabilities and features")
42244225
config: Optional[Dict[str, Any]] = Field(None, description="Agent-specific configuration parameters")
4226+
passthrough_headers: Optional[List[str]] = Field(default=None, description="List of headers allowed to be passed through from client to target")
42254227
auth_type: Optional[str] = Field(None, description="Type of authentication")
42264228
auth_username: Optional[str] = Field(None, description="username for basic authentication")
42274229
auth_password: Optional[str] = Field(None, description="password for basic authentication")
@@ -4529,7 +4531,7 @@ class A2AAgentRead(BaseModelWithConfigDict):
45294531
last_interaction: Optional[datetime]
45304532
tags: List[str] = Field(default_factory=list, description="Tags for categorizing the agent")
45314533
metrics: A2AAgentMetrics
4532-
4534+
passthrough_headers: Optional[List[str]] = Field(default=None, description="List of headers allowed to be passed through from client to target")
45334535
# Authorizations
45344536
auth_type: Optional[str] = Field(None, description="auth_type: basic, bearer, headers, oauth, or None")
45354537
auth_value: Optional[str] = Field(None, description="auth value: username/password or token or custom headers")

mcpgateway/services/a2a_service.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ async def register_agent(
250250
auth_value=auth_value, # This should be encrypted in practice
251251
oauth_config=oauth_config,
252252
tags=agent_data.tags,
253+
passthrough_headers=getattr(agent_data, "passthrough_headers", None),
253254
# Team scoping fields - use schema values if provided, otherwise fallback to parameters
254255
team_id=getattr(agent_data, "team_id", None) or team_id,
255256
owner_email=getattr(agent_data, "owner_email", None) or owner_email or created_by,
@@ -618,7 +619,25 @@ async def update_agent(
618619
raise A2AAgentNameConflictError(name=new_slug, is_active=existing_agent.enabled, agent_id=existing_agent.id, visibility=existing_agent.visibility)
619620
# Update fields
620621
update_data = agent_data.model_dump(exclude_unset=True)
622+
621623
for field, value in update_data.items():
624+
if field == "passthrough_headers":
625+
if value is not None:
626+
if isinstance(value, list):
627+
# Clean list: remove empty or whitespace-only entries
628+
cleaned = [h.strip() for h in value if isinstance(h, str) and h.strip()]
629+
agent.passthrough_headers = cleaned or None
630+
elif isinstance(value, str):
631+
# Parse comma-separated string and clean
632+
parsed: List[str] = [h.strip() for h in value.split(",") if h.strip()]
633+
agent.passthrough_headers = parsed or None
634+
else:
635+
raise A2AAgentError("Invalid passthrough_headers format: must be list[str] or comma-separated string")
636+
else:
637+
# Explicitly set to None if value is None
638+
agent.passthrough_headers = None
639+
continue
640+
622641
if hasattr(agent, field):
623642
setattr(agent, field, value)
624643

mcpgateway/static/admin.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3049,6 +3049,22 @@ async function editA2AAgent(agentId) {
30493049

30503050
// Set form action to the new POST endpoint
30513051

3052+
// Handle passthrough headers
3053+
const passthroughHeadersField = safeGetElement(
3054+
"edit-a2a-agent-passthrough-headers",
3055+
);
3056+
if (passthroughHeadersField) {
3057+
if (
3058+
agent.passthroughHeaders &&
3059+
Array.isArray(agent.passthroughHeaders)
3060+
) {
3061+
passthroughHeadersField.value =
3062+
agent.passthroughHeaders.join(", ");
3063+
} else {
3064+
passthroughHeadersField.value = "";
3065+
}
3066+
}
3067+
30523068
openModal("a2a-edit-modal");
30533069
console.log("✓ A2A Agent edit modal loaded successfully");
30543070
} catch (err) {
@@ -8490,7 +8506,6 @@ async function handleA2AFormSubmit(e) {
84908506
try {
84918507
// Basic validation
84928508
const name = formData.get("name");
8493-
84948509
const nameValidation = validateInputName(name, "A2A Agent");
84958510
if (!nameValidation.valid) {
84968511
throw new Error(nameValidation.error);
@@ -8506,6 +8521,32 @@ async function handleA2AFormSubmit(e) {
85068521

85078522
const isInactiveCheckedBool = isInactiveChecked("a2a-agents");
85088523
formData.append("is_inactive_checked", isInactiveCheckedBool);
8524+
// Process passthrough headers - convert comma-separated string to array
8525+
const passthroughHeadersString = formData.get("passthrough_headers");
8526+
if (passthroughHeadersString && passthroughHeadersString.trim()) {
8527+
// Split by comma and clean up each header name
8528+
const passthroughHeaders = passthroughHeadersString
8529+
.split(",")
8530+
.map((header) => header.trim())
8531+
.filter((header) => header.length > 0);
8532+
8533+
// Validate each header name
8534+
for (const headerName of passthroughHeaders) {
8535+
if (!HEADER_NAME_REGEX.test(headerName)) {
8536+
showErrorMessage(
8537+
`Invalid passthrough header name: "${headerName}". Only letters, numbers, and hyphens are allowed.`,
8538+
);
8539+
return;
8540+
}
8541+
}
8542+
8543+
// Remove the original string and add as JSON array
8544+
formData.delete("passthrough_headers");
8545+
formData.append(
8546+
"passthrough_headers",
8547+
JSON.stringify(passthroughHeaders),
8548+
);
8549+
}
85098550

85108551
// Handle auth_headers JSON field
85118552
const authHeadersJson = formData.get("auth_headers");
@@ -8847,7 +8888,6 @@ async function handleEditA2AAgentFormSubmit(e) {
88478888
throw new Error(urlValidation.error);
88488889
}
88498890

8850-
/*
88518891
// Handle passthrough headers
88528892
const passthroughHeadersString =
88538893
formData.get("passthrough_headers") || "";
@@ -8870,7 +8910,6 @@ async function handleEditA2AAgentFormSubmit(e) {
88708910
"passthrough_headers",
88718911
JSON.stringify(passthroughHeaders),
88728912
);
8873-
*/
88748913

88758914
// Handle OAuth configuration
88768915
// NOTE: OAuth config assembly is now handled by the backend (mcpgateway/admin.py)

mcpgateway/templates/admin.html

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5374,7 +5374,7 @@ <h2 class="text-2xl font-bold dark:text-gray-200">
53745374
</div>
53755375

53765376
<!-- A2A Agents List -->
5377-
<div class="bg-white shadow rounded-lg p-6 mb-6 dark:bg-gray-800">
5377+
<div class="bg-white shadow rounded-lg p-4 mb-4 dark:bg-gray-800">
53785378
<div class="p-0">
53795379
<h3
53805380
class="text-lg font-medium text-gray-900 dark:text-gray-200 mb-4"
@@ -6060,6 +6060,27 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-200 mb-4">
60606060
</div>
60616061
</div>
60626062

6063+
<div class="sm:col-span-2 max-w-5xl">
6064+
<label
6065+
for="passthrough_headers"
6066+
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
6067+
>
6068+
Passthrough Headers
6069+
</label>
6070+
<input
6071+
type="text"
6072+
name="passthrough_headers"
6073+
placeholder="Authorization, X-Tenant-Id, X-Trace-Id"
6074+
class="mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm
6075+
focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
6076+
/>
6077+
<small class="block mt-1 text-sm text-gray-500 dark:text-gray-400">
6078+
List of headers to pass through from client requests (comma-separated, e.g.,
6079+
"Authorization, X-Tenant-Id, X-Trace-Id").
6080+
</small>
6081+
</div>
6082+
6083+
60636084
<!-- Error Display -->
60646085
<span
60656086
id="a2aFormError"
@@ -8699,6 +8720,27 @@ <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
86998720
</label>
87008721
</div>
87018722
</div>
8723+
8724+
<div>
8725+
<label
8726+
class="block text-sm font-medium text-gray-700 dark:text-gray-400"
8727+
>Passthrough Headers</label
8728+
>
8729+
<small
8730+
class="text-gray-500 dark:text-gray-400 block mb-2"
8731+
>
8732+
List of headers to pass through from client requests
8733+
(comma-separated, e.g., "Authorization, X-Tenant-Id,
8734+
X-Trace-Id"). Leave empty to use global defaults.
8735+
</small>
8736+
<input
8737+
type="text"
8738+
name="passthrough_headers"
8739+
id="edit-a2a-agent-passthrough-headers"
8740+
placeholder="Authorization, X-Tenant-Id, X-Trace-Id"
8741+
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:bg-gray-900 dark:placeholder-gray-300 dark:text-gray-300"
8742+
/>
8743+
</div>
87028744

87038745
<!-- Hidden Config Fields -->
87048746
<input type="hidden" name="capabilities" id="a2a-agent-capabilities-edit" value="{}" />

0 commit comments

Comments
 (0)