Skip to content
Closed
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
22 changes: 11 additions & 11 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@

#### **API & Platform**

- **A2A Agent Card re-sync and drift** ([#4734](https://github.com/IBM/mcp-context-forge/issues/4734)) - `POST /a2a/{agent_id}/card/resync` re-fetches the stored card with `If-None-Match`. A 304, or a card with no changes, changes nothing. Descriptive changes, such as description, skills, and icon, apply at once as a new card version. A change to `supportedInterfaces`, `securitySchemes`, `securityRequirements`, or `capabilities.streaming` changes how the gateway calls the agent, so it waits as a pending drift while the current card keeps serving. `POST /a2a/{agent_id}/card/drift` with `apply` or `decline` decides it. A declined drift leaves the agent usable and marked drifted. Applying a card updates only the agent `protocol_version` and card-owned `capabilities`. It never rewrites `endpoint_url` or credentials, and the drift report lists those under `unapplied`. `GET /a2a/{agent_id}/stored-card` now returns `drifted` and `drift`. Both routes need `a2a.update` and agent ownership. Alembic revision `b7d1f4a9c2e6` adds `status` and `drift` columns to `a2a_agent_cards`.

- **Stored A2A Agent Cards** ([#4734](https://github.com/IBM/mcp-context-forge/issues/4734)) - `POST /a2a` accepts an optional `card_url`. The gateway fetches the card itself, rejects the registration when no card is found, and stores the card as fetched in the new `a2a_agent_cards` table, with source URL, detected card version, `ETag`, `Cache-Control` max-age, and fetch time. A card sent by the client is never accepted. `GET /a2a/{agent_id}/stored-card` returns the current stored card. `POST /a2a/fetch-card` also returns `etag` and `max_age`. Registration without `card_url` is unchanged. Alembic revision `a4c8e2f1b7d9` adds the table.

- **A2A Agent Card fetch route** ([#4734](https://github.com/IBM/mcp-context-forge/issues/4734)) - `POST /a2a/fetch-card` (also `/v1/a2a/fetch-card`) fetches an Agent Card from an unregistered endpoint. It tries `/.well-known/agent-card.json`, then `/.well-known/agent.json` on a 404. It returns the card normalized to the A2A 1.0 shape, the card as fetched, and a suggested mapping onto `A2AAgentCreate` fields. The route needs `a2a.create`, applies the outbound URL policy with DNS pinning, and returns one identical `found: false` response for every failure. The card is not stored yet.

- **Product-terminology request aliases for `gateway_id` and the virtual-server `server` body key** ([#6544](https://github.com/IBM/mcp-context-forge/issues/6544)) - Tool, resource, and prompt create/update bodies, and the gateway/MCP-server handshake test request, now also accept `mcp_server_id` as an alias of `gateway_id` ("MCP server" is the product term for what the code calls a "gateway"). The tool, resource, and prompt list endpoints and `GET /v1/search` accept `mcp_server_id` as a query alias of `gateway_id`. `POST /servers` and `POST /v1/virtual-servers` also accept a top-level `virtual_server` key as an alias of `server`. Sending two spellings with different values returns 422. Existing field names and body keys keep working, OpenAPI property names are unchanged, and responses keep the existing key names. In OpenAPI, the `Body_create_server_*` request components are replaced by `ServerCreateRequest`. Output-side aliasing, RBAC permission strings, audit `resource_type` values, and config names remain tracked under #6544.

## [1.1.0] - 2026-10-19 - Modern MCP Protocol, New Redesigned Web UI, Federated Completions, and Catalog Pagination
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""Location: ./mcpgateway/alembic/versions/a4c8e2f1b7d9_add_a2a_agent_cards_table.py
Copyright contributors to the MCP-CONTEXT-FORGE project
SPDX-License-Identifier: Apache-2.0

add_a2a_agent_cards_table

Revision ID: a4c8e2f1b7d9
Revises: d3f6a9b1c5e8
Create Date: 2026-10-29 13:45:00.000000
"""

# Standard
from typing import Sequence, Union

# Third-Party
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "a4c8e2f1b7d9" # pragma: allowlist secret
down_revision: Union[str, Sequence[str], None] = "d3f6a9b1c5e8" # pragma: allowlist secret
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Create the a2a_agent_cards table if it does not already exist."""
inspector = sa.inspect(op.get_bind())
if "a2a_agents" not in inspector.get_table_names():
return
if "a2a_agent_cards" in inspector.get_table_names():
return

op.create_table(
"a2a_agent_cards",
sa.Column("id", sa.String(36), primary_key=True, nullable=False),
sa.Column("a2a_agent_id", sa.String(36), sa.ForeignKey("a2a_agents.id", ondelete="CASCADE"), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("card", sa.JSON(), nullable=False),
sa.Column("detected_version", sa.String(20), nullable=False),
sa.Column("source_url", sa.String(2048), nullable=False),
sa.Column("etag", sa.String(255), nullable=True),
sa.Column("max_age", sa.Integer(), nullable=True),
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("a2a_agent_id", "version", name="uq_a2a_agent_cards_agent_version"),
)
op.create_index("ix_a2a_agent_cards_a2a_agent_id", "a2a_agent_cards", ["a2a_agent_id"])


def downgrade() -> None:
"""Drop the a2a_agent_cards table if it exists."""
inspector = sa.inspect(op.get_bind())
if "a2a_agent_cards" not in inspector.get_table_names():
return

op.drop_index("ix_a2a_agent_cards_a2a_agent_id", table_name="a2a_agent_cards")
op.drop_table("a2a_agent_cards")
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Location: ./mcpgateway/alembic/versions/b7d1f4a9c2e6_add_status_and_drift_to_a2a_agent_cards.py
Copyright contributors to the MCP-CONTEXT-FORGE project
SPDX-License-Identifier: Apache-2.0

add_status_and_drift_to_a2a_agent_cards

Revision ID: b7d1f4a9c2e6
Revises: a4c8e2f1b7d9
Create Date: 2026-10-29 15:00:00.000000
"""

# Standard
from typing import Sequence, Union

# Third-Party
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "b7d1f4a9c2e6" # pragma: allowlist secret
down_revision: Union[str, Sequence[str], None] = "a4c8e2f1b7d9" # pragma: allowlist secret
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add status and drift columns to a2a_agent_cards if they do not exist."""
inspector = sa.inspect(op.get_bind())
if "a2a_agent_cards" not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns("a2a_agent_cards")}
if "status" not in columns:
op.add_column("a2a_agent_cards", sa.Column("status", sa.String(20), nullable=False, server_default="applied"))
if "drift" not in columns:
op.add_column("a2a_agent_cards", sa.Column("drift", sa.JSON(), nullable=True))


def downgrade() -> None:
"""Drop status and drift columns from a2a_agent_cards if they exist."""
inspector = sa.inspect(op.get_bind())
if "a2a_agent_cards" not in inspector.get_table_names():
return
columns = {col["name"] for col in inspector.get_columns("a2a_agent_cards")}
with op.batch_alter_table("a2a_agent_cards") as batch:
if "drift" in columns:
batch.drop_column("drift")
if "status" in columns:
batch.drop_column("status")
33 changes: 33 additions & 0 deletions mcpgateway/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -5039,6 +5039,7 @@ class A2AAgent(Base):
metrics: Mapped[List["A2AAgentMetric"]] = relationship("A2AAgentMetric", back_populates="a2a_agent", cascade="all, delete-orphan")
tasks: Mapped[List["A2ATask"]] = relationship("A2ATask", back_populates="agent", cascade="all, delete-orphan")
auth_config: Mapped[Optional["A2AAgentAuth"]] = relationship("A2AAgentAuth", back_populates="agent", uselist=False, cascade="all, delete-orphan")
cards: Mapped[List["A2AAgentCard"]] = relationship("A2AAgentCard", back_populates="agent", cascade="all, delete-orphan", order_by="A2AAgentCard.version")
__table_args__ = (
UniqueConstraint("team_id", "owner_email", "slug", name="uq_team_owner_slug_a2a_agent"),
Index("idx_a2a_agents_created_at_id", "created_at", "id"),
Expand Down Expand Up @@ -5180,6 +5181,38 @@ def __repr__(self) -> str:
return f"<A2ATask(id='{self.id}', task_id='{self.task_id}', state='{self.state}')>"


class A2AAgentCard(Base):
"""An Agent Card stored as fetched, one row per fetched version.

The card document stays separate from the deployment settings on ``A2AAgent``.
The current card of an agent is its highest ``version`` with status ``applied``.
A fetched card that changes gateway behaviour waits as ``pending`` until confirmed,
and stays as ``declined`` when refused; an agent with such a row is drifted.
"""

__tablename__ = "a2a_agent_cards"

id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex)
a2a_agent_id: Mapped[str] = mapped_column(String(36), ForeignKey("a2a_agents.id", ondelete="CASCADE"), nullable=False, index=True)
version: Mapped[int] = mapped_column(Integer, nullable=False)
card: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False)
detected_version: Mapped[str] = mapped_column(String(20), nullable=False)
source_url: Mapped[str] = mapped_column(String(2048), nullable=False)
etag: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
max_age: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="applied", server_default="applied")
drift: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)

__table_args__ = (UniqueConstraint("a2a_agent_id", "version", name="uq_a2a_agent_cards_agent_version"),)

agent: Mapped["A2AAgent"] = relationship("A2AAgent", back_populates="cards")

def __repr__(self) -> str:
"""Return a string representation of the A2AAgentCard instance."""
return f"<A2AAgentCard(id='{self.id}', a2a_agent_id='{self.a2a_agent_id}', version={self.version})>"


class ServerTaskMapping(Base):
"""Maps server-level task IDs to downstream agent task IDs for A2A federation."""

Expand Down
Loading
Loading