diff --git a/.env.example b/.env.example index 357dbfab1..8c890b4aa 100644 --- a/.env.example +++ b/.env.example @@ -244,3 +244,6 @@ RELOAD=false # Enable verbose logging/debug traces DEBUG=false + +# Gateway tool name separator +GATEWAY_TOOL_NAME_SEPARATOR=- diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e7b6640f..20dbb6827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) --- + +## [0.1.2] - 2025-06-27 (pending) + +* Allow tool federation across gateways by **allowing tools of same names** from different MCP servers to be added. +* **Fix tool list refresh** from Deactivate/Activate cycles and Edit Gateway screen. +* **Improve tool selection experience for servers** by allowing selection based on name from a dropdown. + + ## [0.2.0] - 2025-06-24 ### Added diff --git a/Containerfile b/Containerfile index d5dafe6a0..ba581d7de 100644 --- a/Containerfile +++ b/Containerfile @@ -23,7 +23,7 @@ COPY . /app # Create virtual environment, upgrade pip and install dependencies using uv for speed RUN python3 -m venv /app/.venv && \ /app/.venv/bin/python3 -m pip install --upgrade pip setuptools pdm uv && \ - /app/.venv/bin/python3 -m uv pip install ".[redis,postgres]" + /app/.venv/bin/python3 -m uv pip install ".[redis,postgres,alembic]" # update the user permissions RUN chown -R 1001:0 /app && \ diff --git a/alembic/README b/alembic/README new file mode 100644 index 000000000..e2dc484e0 --- /dev/null +++ b/alembic/README @@ -0,0 +1,71 @@ +# Database Migrations (Alembic) + +This directory contains database migration scripts managed by Alembic. These scripts track and apply changes to the application's database schema over time. + +The migration history for this project lives in the `alembic/versions/` directory. + +--- + +## Common Workflow + +The standard workflow involves creating, reviewing, and applying migrations. + +- **To create a new migration:** `alembic revision --autogenerate -m "Your description"` +- **To apply migrations:** `alembic upgrade head` +- **To see migration history:** `alembic history` +- **To see the current version:** `alembic current` + +--- + +### 1. Creating a New Migration + +When you change a SQLAlchemy model (e.g., in `db.py`), follow these steps to generate a migration script: + +**Step 1: Make your model changes.** +Add, remove, or alter columns and tables in your SQLAlchemy model definitions. + +**Step 2: Autogenerate the script.** +Run the following command from the project's root directory. Use a short but descriptive message. + +```bash +alembic revision --autogenerate -m "Add slug and url to gateways table" +``` +**Step 3: Review and Edit the Script (CRITICAL STEP).** +A new file will be created in alembic/versions/. Always open and review this file. + +Autogenerate is a starting point, not a final answer. It is good at detecting new columns and tables. + +It often requires significant manual editing for complex changes like: + +- Data migrations (populating new columns). + +- Renaming columns or tables. + +- Changes that require multi-stage operations (adding a column as nullable, populating it, then making it not-nullable). + +- Ensure the upgrade() and downgrade() functions are correct and logical. + +### 2. Applying Migrations + +To upgrade your database to the latest version: +This command applies all pending migrations. This is the command used by developers locally and by the CI/CD pipeline during deployment. +```bash +alembic upgrade head +``` + +To test your downgrade path (local development only): +It's good practice to ensure your migrations are reversible. +#### Revert the very last migration +```bash +alembic downgrade -1 +``` +#### You can then re-apply it +```bash +alembic upgrade +1 +``` + +### 3. Deployment Notes + +**CI/CD:** During deployment, the alembic upgrade head command is run automatically to synchronize the database schema with the new application code before the server starts. + +**Configuration:** The sqlalchemy.url in alembic.ini is replaced by the value set for DATABASE_URL environment variable, which env.py is configured to read. diff --git a/alembic/versions/1ebe90768201_initial_schema.py b/alembic/versions/1ebe90768201_initial_schema.py deleted file mode 100644 index 2be97449f..000000000 --- a/alembic/versions/1ebe90768201_initial_schema.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -"""initial schema - -Revision ID: 1ebe90768201 -Revises: -Create Date: 2025-06-27 07:47:54.593692 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '1ebe90768201' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/alembic/versions/b77ca9d2de7e_uuid_pk_and_slug_refactor.py b/alembic/versions/b77ca9d2de7e_uuid_pk_and_slug_refactor.py new file mode 100644 index 000000000..f91e0b8c7 --- /dev/null +++ b/alembic/versions/b77ca9d2de7e_uuid_pk_and_slug_refactor.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +"""uuid-pk_and_slug_refactor + +Revision ID: b77ca9d2de7e +Revises: +Create Date: 2025-06-26 21:29:59.117140 + +""" +import uuid +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from alembic import op +from mcpgateway.config import settings +from mcpgateway.utils.create_slug import slugify + +# revision identifiers, used by Alembic. +revision: str = 'b77ca9d2de7e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── +def _use_batch() -> bool: + return op.get_bind().dialect.name == "sqlite" + + +# ────────────────────────────────────────────────────────────────────────────── +# Upgrade +# ────────────────────────────────────────────────────────────────────────────── +def upgrade() -> None: + bind = op.get_bind() + sess = Session(bind=bind) + inspector = sa.inspect(bind) + + if not inspector.has_table("gateways"): + print("Fresh database detected. Skipping migration.") + return + + print("Existing installation detected. Starting data and schema migration...") + + # ── STAGE 1: ADD NEW NULLABLE COLUMNS AS PLACEHOLDERS ───────────────── + op.add_column("gateways", sa.Column("slug", sa.String(), nullable=True)) + op.add_column("gateways", sa.Column("id_new", sa.String(36), nullable=True)) + + op.add_column("tools", sa.Column("id_new", sa.String(36), nullable=True)) + op.add_column("tools", sa.Column("original_name", sa.String(), nullable=True)) + op.add_column("tools", sa.Column("original_name_slug", sa.String(), nullable=True)) + op.add_column("tools", sa.Column("name_new", sa.String(), nullable=True)) + op.add_column("tools", sa.Column("gateway_id_new", sa.String(36), nullable=True)) + + op.add_column("resources", sa.Column("gateway_id_new", sa.String(36), nullable=True)) + op.add_column("prompts", sa.Column("gateway_id_new", sa.String(36), nullable=True)) + + op.add_column("servers", sa.Column("id_new", sa.String(36), nullable=True)) + + op.add_column("server_tool_association", sa.Column("server_id_new", sa.String(36), nullable=True)) + op.add_column("server_tool_association", sa.Column("tool_id_new", sa.String(36), nullable=True)) + + op.add_column("tool_metrics", sa.Column("tool_id_new", sa.String(36), nullable=True)) + op.add_column("server_metrics", sa.Column("server_id_new", sa.String(36), nullable=True)) + op.add_column("server_resource_association", sa.Column("server_id_new", sa.String(36), nullable=True)) + op.add_column("server_prompt_association", sa.Column("server_id_new", sa.String(36), nullable=True)) + + + # ── STAGE 2: POPULATE THE NEW COLUMNS (DATA MIGRATION) ─────────────── + gateways = sess.execute(sa.select(sa.text("id, name")).select_from(sa.text("gateways"))).all() + for gid, gname in gateways: + g_uuid = uuid.uuid4().hex + sess.execute( + sa.text("UPDATE gateways SET id_new=:u, slug=:s WHERE id=:i"), + {"u": g_uuid, "s": slugify(gname), "i": gid}, + ) + + tools = sess.execute( + sa.select(sa.text("id, name, gateway_id")).select_from(sa.text("tools")) + ).all() + for tid, tname, g_old in tools: + t_uuid = uuid.uuid4().hex + tool_slug = slugify(tname) + sess.execute( + sa.text( + """ + UPDATE tools + SET id_new=:u, + original_name=:on, + original_name_slug=:ons, + name_new = CASE + WHEN :g IS NOT NULL THEN (SELECT slug FROM gateways WHERE id = :g) || :sep || :ons + ELSE :ons + END, + gateway_id_new=(SELECT id_new FROM gateways WHERE id=:g) + WHERE id=:i + """ + ), + { + "u": t_uuid, "on": tname, "ons": tool_slug, + "sep": settings.gateway_tool_name_separator, "g": g_old, "i": tid, + }, + ) + + servers = sess.execute(sa.select(sa.text("id")).select_from(sa.text("servers"))).all() + for (sid,) in servers: + sess.execute( + sa.text("UPDATE servers SET id_new=:u WHERE id=:i"), + {"u": uuid.uuid4().hex, "i": sid}, + ) + + # Populate all dependent tables + resources = sess.execute(sa.select(sa.text("id, gateway_id")).select_from(sa.text("resources"))).all() + for rid, g_old in resources: + sess.execute(sa.text("UPDATE resources SET gateway_id_new=(SELECT id_new FROM gateways WHERE id=:g) WHERE id=:i"), {"g": g_old, "i": rid}) + prompts = sess.execute(sa.select(sa.text("id, gateway_id")).select_from(sa.text("prompts"))).all() + for pid, g_old in prompts: + sess.execute(sa.text("UPDATE prompts SET gateway_id_new=(SELECT id_new FROM gateways WHERE id=:g) WHERE id=:i"), {"g": g_old, "i": pid}) + sta = sess.execute(sa.select(sa.text("server_id, tool_id")).select_from(sa.text("server_tool_association"))).all() + for s_old, t_old in sta: + sess.execute(sa.text("UPDATE server_tool_association SET server_id_new=(SELECT id_new FROM servers WHERE id=:s), tool_id_new=(SELECT id_new FROM tools WHERE id=:t) WHERE server_id=:s AND tool_id=:t"), {"s": s_old, "t": t_old}) + tool_metrics = sess.execute(sa.select(sa.text("id, tool_id")).select_from(sa.text("tool_metrics"))).all() + for tmid, t_old in tool_metrics: + sess.execute(sa.text("UPDATE tool_metrics SET tool_id_new=(SELECT id_new FROM tools WHERE id=:t) WHERE id=:i"), {"t": t_old, "i": tmid}) + server_metrics = sess.execute(sa.select(sa.text("id, server_id")).select_from(sa.text("server_metrics"))).all() + for smid, s_old in server_metrics: + sess.execute(sa.text("UPDATE server_metrics SET server_id_new=(SELECT id_new FROM servers WHERE id=:s) WHERE id=:i"), {"s": s_old, "i": smid}) + server_resource_assoc = sess.execute(sa.select(sa.text("server_id, resource_id")).select_from(sa.text("server_resource_association"))).all() + for s_old, r_id in server_resource_assoc: + sess.execute(sa.text("UPDATE server_resource_association SET server_id_new=(SELECT id_new FROM servers WHERE id=:s) WHERE server_id=:s AND resource_id=:r"), {"s": s_old, "r": r_id}) + server_prompt_assoc = sess.execute(sa.select(sa.text("server_id, prompt_id")).select_from(sa.text("server_prompt_association"))).all() + for s_old, p_id in server_prompt_assoc: + sess.execute(sa.text("UPDATE server_prompt_association SET server_id_new=(SELECT id_new FROM servers WHERE id=:s) WHERE server_id=:s AND prompt_id=:p"), {"s": s_old, "p": p_id}) + + sess.commit() + + # ── STAGE 3: FINALIZE SCHEMA (CORRECTED ORDER) ─────────────────────── + # First, rebuild all tables that depend on `servers` and `gateways`. + # This implicitly drops their old foreign key constraints. + with op.batch_alter_table("server_tool_association") as batch_op: + batch_op.drop_column("server_id") + batch_op.drop_column("tool_id") + batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + batch_op.alter_column("tool_id_new", new_column_name="tool_id", nullable=False) + batch_op.create_primary_key("pk_server_tool_association", ["server_id", "tool_id"]) + + with op.batch_alter_table("server_resource_association") as batch_op: + batch_op.drop_column("server_id") + batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + + with op.batch_alter_table("server_prompt_association") as batch_op: + batch_op.drop_column("server_id") + batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + + with op.batch_alter_table("server_metrics") as batch_op: + batch_op.drop_column("server_id") + batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + + with op.batch_alter_table("tool_metrics") as batch_op: + batch_op.drop_column("tool_id") + batch_op.alter_column("tool_id_new", new_column_name="tool_id", nullable=False) + + with op.batch_alter_table("tools") as batch_op: + batch_op.drop_column("id") + batch_op.alter_column("id_new", new_column_name="id", nullable=False) + batch_op.create_primary_key("pk_tools", ["id"]) + batch_op.drop_column("gateway_id") + batch_op.alter_column("gateway_id_new", new_column_name="gateway_id", nullable=True) + batch_op.drop_column("name") + batch_op.alter_column("name_new", new_column_name="name", nullable=True) + batch_op.alter_column("original_name", nullable=False) + batch_op.alter_column("original_name_slug", nullable=False) + batch_op.create_unique_constraint("uq_tools_name", ["name"]) + batch_op.create_unique_constraint("uq_gateway_id__original_name", ["gateway_id", "original_name"]) + + with op.batch_alter_table("resources") as batch_op: + batch_op.drop_column("gateway_id") + batch_op.alter_column("gateway_id_new", new_column_name="gateway_id", nullable=True) + + with op.batch_alter_table("prompts") as batch_op: + batch_op.drop_column("gateway_id") + batch_op.alter_column("gateway_id_new", new_column_name="gateway_id", nullable=True) + + # Second, now that no tables point to their old IDs, rebuild `gateways` and `servers`. + with op.batch_alter_table("gateways") as batch_op: + batch_op.drop_column("id") + batch_op.alter_column("id_new", new_column_name="id", nullable=False) + batch_op.create_primary_key("pk_gateways", ["id"]) + batch_op.alter_column("slug", nullable=False) + batch_op.create_unique_constraint("uq_gateways_slug", ["slug"]) + batch_op.create_unique_constraint("uq_gateways_url", ["url"]) + + with op.batch_alter_table("servers") as batch_op: + batch_op.drop_column("id") + batch_op.alter_column("id_new", new_column_name="id", nullable=False) + batch_op.create_primary_key("pk_servers", ["id"]) + + # Finally, recreate all the foreign key constraints in batch mode for SQLite compatibility. + # The redundant `source_table` argument has been removed from each call. + with op.batch_alter_table("tools") as batch_op: + batch_op.create_foreign_key("fk_tools_gateway_id", "gateways", ["gateway_id"], ["id"]) + with op.batch_alter_table("resources") as batch_op: + batch_op.create_foreign_key("fk_resources_gateway_id", "gateways", ["gateway_id"], ["id"]) + with op.batch_alter_table("prompts") as batch_op: + batch_op.create_foreign_key("fk_prompts_gateway_id", "gateways", ["gateway_id"], ["id"]) + with op.batch_alter_table("server_tool_association") as batch_op: + batch_op.create_foreign_key("fk_server_tool_association_servers", "servers", ["server_id"], ["id"]) + batch_op.create_foreign_key("fk_server_tool_association_tools", "tools", ["tool_id"], ["id"]) + with op.batch_alter_table("tool_metrics") as batch_op: + batch_op.create_foreign_key("fk_tool_metrics_tool_id", "tools", ["tool_id"], ["id"]) + with op.batch_alter_table("server_metrics") as batch_op: + batch_op.create_foreign_key("fk_server_metrics_server_id", "servers", ["server_id"], ["id"]) + with op.batch_alter_table("server_resource_association") as batch_op: + batch_op.create_foreign_key("fk_server_resource_association_server_id", "servers", ["server_id"], ["id"]) + with op.batch_alter_table("server_prompt_association") as batch_op: + batch_op.create_foreign_key("fk_server_prompt_association_server_id", "servers", ["server_id"], ["id"]) + +# def upgrade() -> None: +# bind = op.get_bind() +# sess = Session(bind=bind) +# inspector = sa.inspect(bind) + +# if not inspector.has_table("gateways"): +# print("Fresh database detected. Skipping migration.") +# return + +# print("Existing installation detected. Starting data and schema migration...") + +# # ── STAGE 1: ADD NEW NULLABLE COLUMNS AS PLACEHOLDERS ───────────────── +# op.add_column("gateways", sa.Column("slug", sa.String(), nullable=True)) +# op.add_column("gateways", sa.Column("id_new", sa.String(36), nullable=True)) + +# op.add_column("tools", sa.Column("id_new", sa.String(36), nullable=True)) +# op.add_column("tools", sa.Column("original_name", sa.String(), nullable=True)) +# op.add_column("tools", sa.Column("original_name_slug", sa.String(), nullable=True)) +# op.add_column("tools", sa.Column("name_new", sa.String(), nullable=True)) +# op.add_column("tools", sa.Column("gateway_id_new", sa.String(36), nullable=True)) + +# op.add_column("resources", sa.Column("gateway_id_new", sa.String(36), nullable=True)) +# op.add_column("prompts", sa.Column("gateway_id_new", sa.String(36), nullable=True)) + +# op.add_column("servers", sa.Column("id_new", sa.String(36), nullable=True)) + +# op.add_column("server_tool_association", sa.Column("server_id_new", sa.String(36), nullable=True)) +# op.add_column("server_tool_association", sa.Column("tool_id_new", sa.String(36), nullable=True)) + +# op.add_column("tool_metrics", sa.Column("tool_id_new", sa.String(36), nullable=True)) + +# # Add columns for the new server dependencies +# op.add_column("server_metrics", sa.Column("server_id_new", sa.String(36), nullable=True)) +# op.add_column("server_resource_association", sa.Column("server_id_new", sa.String(36), nullable=True)) +# op.add_column("server_prompt_association", sa.Column("server_id_new", sa.String(36), nullable=True)) + + +# # ── STAGE 2: POPULATE THE NEW COLUMNS (DATA MIGRATION) ─────────────── +# gateways = sess.execute(sa.select(sa.text("id, name")).select_from(sa.text("gateways"))).all() +# for gid, gname in gateways: +# g_uuid = uuid.uuid4().hex +# sess.execute( +# sa.text("UPDATE gateways SET id_new=:u, slug=:s WHERE id=:i"), +# {"u": g_uuid, "s": slugify(gname), "i": gid}, +# ) + +# tools = sess.execute( +# sa.select(sa.text("id, name, gateway_id")).select_from(sa.text("tools")) +# ).all() +# for tid, tname, g_old in tools: +# t_uuid = uuid.uuid4().hex +# tool_slug = slugify(tname) +# sess.execute( +# sa.text( +# """ +# UPDATE tools +# SET id_new=:u, +# original_name=:on, +# original_name_slug=:ons, +# name_new = CASE +# WHEN :g IS NOT NULL THEN (SELECT slug FROM gateways WHERE id = :g) || :sep || :ons +# ELSE :ons +# END, +# gateway_id_new=(SELECT id_new FROM gateways WHERE id=:g) +# WHERE id=:i +# """ +# ), +# { +# "u": t_uuid, "on": tname, "ons": tool_slug, +# "sep": settings.gateway_tool_name_separator, "g": g_old, "i": tid, +# }, +# ) + +# servers = sess.execute(sa.select(sa.text("id")).select_from(sa.text("servers"))).all() +# for (sid,) in servers: +# sess.execute( +# sa.text("UPDATE servers SET id_new=:u WHERE id=:i"), +# {"u": uuid.uuid4().hex, "i": sid}, +# ) + +# # Populate all dependent tables +# resources = sess.execute(sa.select(sa.text("id, gateway_id")).select_from(sa.text("resources"))).all() +# for rid, g_old in resources: +# sess.execute(sa.text("UPDATE resources SET gateway_id_new=(SELECT id_new FROM gateways WHERE id=:g) WHERE id=:i"), {"g": g_old, "i": rid}) +# prompts = sess.execute(sa.select(sa.text("id, gateway_id")).select_from(sa.text("prompts"))).all() +# for pid, g_old in prompts: +# sess.execute(sa.text("UPDATE prompts SET gateway_id_new=(SELECT id_new FROM gateways WHERE id=:g) WHERE id=:i"), {"g": g_old, "i": pid}) +# sta = sess.execute(sa.select(sa.text("server_id, tool_id")).select_from(sa.text("server_tool_association"))).all() +# for s_old, t_old in sta: +# sess.execute(sa.text("UPDATE server_tool_association SET server_id_new=(SELECT id_new FROM servers WHERE id=:s), tool_id_new=(SELECT id_new FROM tools WHERE id=:t) WHERE server_id=:s AND tool_id=:t"), {"s": s_old, "t": t_old}) +# tool_metrics = sess.execute(sa.select(sa.text("id, tool_id")).select_from(sa.text("tool_metrics"))).all() +# for tmid, t_old in tool_metrics: +# sess.execute(sa.text("UPDATE tool_metrics SET tool_id_new=(SELECT id_new FROM tools WHERE id=:t) WHERE id=:i"), {"t": t_old, "i": tmid}) +# server_metrics = sess.execute(sa.select(sa.text("id, server_id")).select_from(sa.text("server_metrics"))).all() +# for smid, s_old in server_metrics: +# sess.execute(sa.text("UPDATE server_metrics SET server_id_new=(SELECT id_new FROM servers WHERE id=:s) WHERE id=:i"), {"s": s_old, "i": smid}) +# server_resource_assoc = sess.execute(sa.select(sa.text("server_id, resource_id")).select_from(sa.text("server_resource_association"))).all() +# for s_old, r_id in server_resource_assoc: +# sess.execute(sa.text("UPDATE server_resource_association SET server_id_new=(SELECT id_new FROM servers WHERE id=:s) WHERE server_id=:s AND resource_id=:r"), {"s": s_old, "r": r_id}) +# server_prompt_assoc = sess.execute(sa.select(sa.text("server_id, prompt_id")).select_from(sa.text("server_prompt_association"))).all() +# for s_old, p_id in server_prompt_assoc: +# sess.execute(sa.text("UPDATE server_prompt_association SET server_id_new=(SELECT id_new FROM servers WHERE id=:s) WHERE server_id=:s AND prompt_id=:p"), {"s": s_old, "p": p_id}) + +# sess.commit() + +# # ── STAGE 3: FINALIZE SCHEMA (CORRECTED ORDER) ─────────────────────── +# with op.batch_alter_table("server_tool_association") as batch_op: +# batch_op.drop_column("server_id") +# batch_op.drop_column("tool_id") +# batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) +# batch_op.alter_column("tool_id_new", new_column_name="tool_id", nullable=False) +# batch_op.create_primary_key("pk_server_tool_association", ["server_id", "tool_id"]) + +# with op.batch_alter_table("server_resource_association") as batch_op: +# batch_op.drop_column("server_id") +# batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + +# with op.batch_alter_table("server_prompt_association") as batch_op: +# batch_op.drop_column("server_id") +# batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + +# with op.batch_alter_table("server_metrics") as batch_op: +# batch_op.drop_column("server_id") +# batch_op.alter_column("server_id_new", new_column_name="server_id", nullable=False) + +# with op.batch_alter_table("tool_metrics") as batch_op: +# batch_op.drop_column("tool_id") +# batch_op.alter_column("tool_id_new", new_column_name="tool_id", nullable=False) + +# with op.batch_alter_table("tools") as batch_op: +# batch_op.drop_column("id") +# batch_op.alter_column("id_new", new_column_name="id", nullable=False) +# batch_op.create_primary_key("pk_tools", ["id"]) +# batch_op.drop_column("gateway_id") +# batch_op.alter_column("gateway_id_new", new_column_name="gateway_id", nullable=True) +# batch_op.drop_column("name") +# batch_op.alter_column("name_new", new_column_name="name", nullable=False) +# batch_op.alter_column("original_name", nullable=False) +# batch_op.alter_column("original_name_slug", nullable=False) +# batch_op.create_unique_constraint("uq_tools_name", ["name"]) +# batch_op.create_unique_constraint("uq_gateway_id__original_name", ["gateway_id", "original_name"]) + +# with op.batch_alter_table("resources") as batch_op: +# batch_op.drop_column("gateway_id") +# batch_op.alter_column("gateway_id_new", new_column_name="gateway_id", nullable=True) + +# with op.batch_alter_table("prompts") as batch_op: +# batch_op.drop_column("gateway_id") +# batch_op.alter_column("gateway_id_new", new_column_name="gateway_id", nullable=True) + +# with op.batch_alter_table("gateways") as batch_op: +# batch_op.drop_column("id") +# batch_op.alter_column("id_new", new_column_name="id", nullable=False) +# batch_op.create_primary_key("pk_gateways", ["id"]) +# batch_op.alter_column("slug", nullable=False) +# batch_op.create_unique_constraint("uq_gateways_slug", ["slug"]) +# batch_op.create_unique_constraint("uq_gateways_url", ["url"]) + +# with op.batch_alter_table("servers") as batch_op: +# batch_op.drop_column("id") +# batch_op.alter_column("id_new", new_column_name="id", nullable=False) +# batch_op.create_primary_key("pk_servers", ["id"]) + +# # Finally, recreate all the foreign key constraints +# op.create_foreign_key("fk_tools_gateway_id", "tools", "gateways", ["gateway_id"], ["id"]) +# op.create_foreign_key("fk_resources_gateway_id", "resources", "gateways", ["gateway_id"], ["id"]) +# op.create_foreign_key("fk_prompts_gateway_id", "prompts", "gateways", ["gateway_id"], ["id"]) +# op.create_foreign_key("fk_server_tool_association_servers", "server_tool_association", "servers", ["server_id"], ["id"]) +# op.create_foreign_key("fk_server_tool_association_tools", "server_tool_association", "tools", ["tool_id"], ["id"]) +# op.create_foreign_key("fk_tool_metrics_tool_id", "tool_metrics", "tools", ["tool_id"], ["id"]) +# op.create_foreign_key("fk_server_metrics_server_id", "server_metrics", "servers", ["server_id"], ["id"]) +# op.create_foreign_key("fk_server_resource_association_server_id", "server_resource_association", "servers", ["server_id"], ["id"]) +# op.create_foreign_key("fk_server_prompt_association_server_id", "server_prompt_association", "servers", ["server_id"], ["id"]) + + +def downgrade() -> None: + # ── STAGE 1 (REVERSE): Revert Schema to original state ───────────────── + # This reverses the operations from STAGE 3 of the upgrade. + # Data from the new columns will be lost, which is expected. + + with op.batch_alter_table("server_tool_association") as batch_op: + # Drop new constraints + batch_op.drop_constraint("fk_server_tool_association_tools", type_="foreignkey") + batch_op.drop_constraint("fk_server_tool_association_servers", type_="foreignkey") + batch_op.drop_constraint("pk_server_tool_association", type_="primarykey") + # Rename final columns back to temporary names + batch_op.alter_column("server_id", new_column_name="server_id_new") + batch_op.alter_column("tool_id", new_column_name="tool_id_new") + # Add back old integer columns (data is not restored) + batch_op.add_column(sa.Column("server_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("tool_id", sa.Integer(), nullable=True)) + + with op.batch_alter_table("tools") as batch_op: + # Drop new constraints + batch_op.drop_constraint("fk_tools_gateway_id", type_="foreignkey") + batch_op.drop_constraint("uq_gateway_id__original_name", type_="unique") + batch_op.drop_constraint("uq_tools_name", type_="unique") + batch_op.drop_constraint("pk_tools", type_="primarykey") + # Rename final columns back to temporary names + batch_op.alter_column("id", new_column_name="id_new") + batch_op.alter_column("gateway_id", new_column_name="gateway_id_new") + batch_op.alter_column("name", new_column_name="name_new") + # Add back old columns + batch_op.add_column(sa.Column("id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("gateway_id", sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column("name", sa.String(), nullable=True)) + + with op.batch_alter_table("servers") as batch_op: + batch_op.drop_constraint("pk_servers", type_="primarykey") + batch_op.alter_column("id", new_column_name="id_new") + batch_op.add_column(sa.Column("id", sa.Integer(), nullable=True)) + + with op.batch_alter_table("gateways") as batch_op: + batch_op.drop_constraint("uq_gateways_url", type_="unique") + batch_op.drop_constraint("uq_gateways_slug", type_="unique") + batch_op.drop_constraint("pk_gateways", type_="primarykey") + batch_op.alter_column("id", new_column_name="id_new") + batch_op.add_column(sa.Column("id", sa.Integer(), nullable=True)) + + # ── STAGE 2 (REVERSE): Reverse Data Migration (No-Op for Schema) ────── + # Reversing the data population (e.g., creating integer PKs from UUIDs) + # is a complex, stateful operation and is omitted here. At this point, + # the original columns exist but are empty (NULL). + + # ── STAGE 3 (REVERSE): Drop the temporary/new columns ──────────────── + # This reverses the operations from STAGE 1 of the upgrade. + op.drop_column("server_tool_association", "tool_id_new") + op.drop_column("server_tool_association", "server_id_new") + op.drop_column("servers", "id_new") + op.drop_column("tools", "gateway_id_new") + op.drop_column("tools", "name_new") + op.drop_column("tools", "original_name_slug") + op.drop_column("tools", "original_name") + op.drop_column("tools", "id_new") + op.drop_column("gateways", "id_new") + op.drop_column("gateways", "slug") diff --git a/docker-compose.yml b/docker-compose.yml index 68aed216c..291a30ffc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,11 +53,13 @@ services: # - CERT_FILE=/app/certs/cert.pem # - KEY_FILE=/app/certs/key.pem - depends_on: # Default stack: Postgres + Redis + depends_on: # Default stack: Postgres + Redis + Alembic migration postgres: condition: service_healthy # ▶ wait for DB redis: condition: service_started + migration: + condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4444/health"] @@ -119,6 +121,18 @@ services: # volumes: [mongodata:/data/db] # networks: [mcpnet] + migration: + build: + context: . + dockerfile: Containerfile + environment: + - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD:-mysecretpassword}@postgres:5432/mcp + command: alembic upgrade head + depends_on: + postgres: + condition: service_healthy + networks: [mcpnet] + ############################################################################### # CACHE ############################################################################### diff --git a/docs/docs/architecture/adr/011-tool-federation.md b/docs/docs/architecture/adr/011-tool-federation.md new file mode 100644 index 000000000..cbf7053e3 --- /dev/null +++ b/docs/docs/architecture/adr/011-tool-federation.md @@ -0,0 +1,63 @@ +# ADR-0011: Allow gateways to add tools with the same server side name to the MCP Gateway without conflict + +- *Status:* Draft +- *Date:* 2025-06-22 +- *Deciders:* Core Engineering Team + +## Context + +The current functionality only supports unique names for tools, making it hard for addition of tools from different gateways with similar common names. + +This needs to be updated so that tool names are allowed with a combination of gateway name (slugified) and tool name. This would allow servers to add their own versions of the tools. + +The tool names would be stored along with their original name in the database so that the correct server side name is passed while invoking it. + +## Decision + +We implemented this by making the following changes: + +1. **Update IDs from integers to UUIDs**: + - Modify the data type of `id` in `Gateway`, `Tool` and `Server` SQLAlchemy ORM classes from **int** to **str** + - Use a default value of `uuid.uuid4().hex` for the IDs + - Modify `server_id` and `tool_id` to *String* in `server_tool_association` table + +2. **Separate server side and gateway side names for tools**: + - Add a new field called `original_name` in Tool ORM class to store the MCP server side name used for invocation + - Define a hybrid operator `name` to capture how the gateway exposes the tool. Set it as `f"{slugify(self.gateway.name)}{settings.gateway_tool_name_separator}{self.original_name}"` + - Slugified `self.gateway.name` is used to remove spaces in new tool names + - Hybrid operator is used so it can be used in Python and SQL code for filtering and querying + - Add a new field called `gateway_slug` which is defined as the `slug` of the Gateway linked via `self.gateway_id`. This field is later used to extract the original name from name passed from APIs + +3. **Addition of configurable environmental variable `GATEWAY_TOOL_NAME_SEPARATOR`** to set how the tool name looks like: + - By default, this is set to `-` in config.py + +4. **Updates Python object schemas, function data types** to match database ORM changes** + - Change data type of `gateway_id`, `tool_id` and `server_id` from **int** to **str** in API functions + - When storing and updating tools, use `original_name` in `DbTool` objects to store the original name coming from `_initiate_gateway`. + - Remove check for only storing tools without matching original names + - Check if `gateway.url` exists instead of `gateway.name` exists before thowing `GatewayNameConflictError`. + - Check for existing tools on `original_name` and `gateway_id` instead of just `name` (as earlier) in **update_gateway** and **toggle_gateway_status** code. + - Set `name` and `gateway_slug` just before passing to `ToolRead` seprately since these don't come from the database as these are properties and not columns. + - To obtain tool from database for invocation, handle the case that `name` from the API is not stored as a column in the database, but is a property by making an appropriate comparison as `DbTool.gateway_slug + settings.gateway_tool_name_separator + DbTool.original_name == name` + +5. **Handle tool changes from the gateway** by adding and removing tools based on latest deactivate/activate or edit: + - Step 1: Add all tools not present in database based on `original_name` to `gateway.tools` + - Step 2: Remove any tools not sent in the latest call to `_initialize_gateway` from `gateway.tools`. + +6. **Show row index in UI**: + - Display the index of the row with `loop.index` in a new column called `S. No.` in **Gateways**, **Tools** and **Servers** screens. + +## Consequences + +- Two gateways can have the tools with the same native name on the gateway. e.g. `gateway-1-get_current_time` and `gateway-2-get_current_time`. +- If the tools on a gateway change, they will reflect after **Deactivate/Activate** cycle or after **Edit Gateway** action. + +## Alternatives Considered + +| Option | Why Not | +|----------------------------------|----------------------------------------------------------------------| +| **Use qualified_name as display name and name as native MCP server name** | Requires changes at more places since most clients display and call with the field `name`| + +## Status + +PR created: []() diff --git a/docs/docs/architecture/adr/012-dropdown-ui-tool-selection copy.md b/docs/docs/architecture/adr/012-dropdown-ui-tool-selection copy.md new file mode 100644 index 000000000..06ce8d76e --- /dev/null +++ b/docs/docs/architecture/adr/012-dropdown-ui-tool-selection copy.md @@ -0,0 +1,35 @@ +# ADR-0012: Display available tools in a dropdown and allow selection from there for creating a server + +- *Status:* Draft +- *Date:* 2025-06-22 +- *Deciders:* Core Engineering Team + +## Context + +The current solution provides a text box for users where they can enter tool ids to link to a server + +With the change of IDs from integers to UUIDs, this process is more cumbursome. + +This is modified so that users can select from tool names from a drop down. + +## Decision + +We implemented this by making the following changes: + +1. **Replace text box with a dropdown element** keeping the styling consistent with the to the tailwind styling used + - Users select names, but the selected tool `id`s are sent to the API for databse storage + - Make this change in server creation and editing screens + +2. **Add a span to display selected tools** + - Display the selected tools below the dropdown + - Show a warning if more than 6 tools are selected in a server. This is to encourage small servers more suited for use with agents. + +## Screenshots +![Tool selection screen](tool-selection-screen.png) +*Tool selection screen* + +![Tool count warning](tool-count-warning.png) +*Tool count warning* +## Status + +PR created: []() diff --git a/docs/docs/architecture/adr/012-dropdown-ui-tool-selection.md b/docs/docs/architecture/adr/012-dropdown-ui-tool-selection.md new file mode 100644 index 000000000..06ce8d76e --- /dev/null +++ b/docs/docs/architecture/adr/012-dropdown-ui-tool-selection.md @@ -0,0 +1,35 @@ +# ADR-0012: Display available tools in a dropdown and allow selection from there for creating a server + +- *Status:* Draft +- *Date:* 2025-06-22 +- *Deciders:* Core Engineering Team + +## Context + +The current solution provides a text box for users where they can enter tool ids to link to a server + +With the change of IDs from integers to UUIDs, this process is more cumbursome. + +This is modified so that users can select from tool names from a drop down. + +## Decision + +We implemented this by making the following changes: + +1. **Replace text box with a dropdown element** keeping the styling consistent with the to the tailwind styling used + - Users select names, but the selected tool `id`s are sent to the API for databse storage + - Make this change in server creation and editing screens + +2. **Add a span to display selected tools** + - Display the selected tools below the dropdown + - Show a warning if more than 6 tools are selected in a server. This is to encourage small servers more suited for use with agents. + +## Screenshots +![Tool selection screen](tool-selection-screen.png) +*Tool selection screen* + +![Tool count warning](tool-count-warning.png) +*Tool count warning* +## Status + +PR created: []() diff --git a/docs/docs/architecture/adr/013-APIs-for-server-connection-strings.md b/docs/docs/architecture/adr/013-APIs-for-server-connection-strings.md new file mode 100644 index 000000000..06ce8d76e --- /dev/null +++ b/docs/docs/architecture/adr/013-APIs-for-server-connection-strings.md @@ -0,0 +1,35 @@ +# ADR-0012: Display available tools in a dropdown and allow selection from there for creating a server + +- *Status:* Draft +- *Date:* 2025-06-22 +- *Deciders:* Core Engineering Team + +## Context + +The current solution provides a text box for users where they can enter tool ids to link to a server + +With the change of IDs from integers to UUIDs, this process is more cumbursome. + +This is modified so that users can select from tool names from a drop down. + +## Decision + +We implemented this by making the following changes: + +1. **Replace text box with a dropdown element** keeping the styling consistent with the to the tailwind styling used + - Users select names, but the selected tool `id`s are sent to the API for databse storage + - Make this change in server creation and editing screens + +2. **Add a span to display selected tools** + - Display the selected tools below the dropdown + - Show a warning if more than 6 tools are selected in a server. This is to encourage small servers more suited for use with agents. + +## Screenshots +![Tool selection screen](tool-selection-screen.png) +*Tool selection screen* + +![Tool count warning](tool-count-warning.png) +*Tool count warning* +## Status + +PR created: []() diff --git a/docs/docs/architecture/adr/tool-count-warning.png b/docs/docs/architecture/adr/tool-count-warning.png new file mode 100644 index 000000000..1510548fe Binary files /dev/null and b/docs/docs/architecture/adr/tool-count-warning.png differ diff --git a/docs/docs/architecture/adr/tool-selection-screen.png b/docs/docs/architecture/adr/tool-selection-screen.png new file mode 100644 index 000000000..8a7cb8d9c Binary files /dev/null and b/docs/docs/architecture/adr/tool-selection-screen.png differ diff --git a/mcp.backup.db b/mcp.backup.db new file mode 100644 index 000000000..13c923b45 Binary files /dev/null and b/mcp.backup.db differ diff --git a/mcpgateway/admin.py b/mcpgateway/admin.py index 00541b2da..63b75118a 100644 --- a/mcpgateway/admin.py +++ b/mcpgateway/admin.py @@ -102,12 +102,12 @@ async def admin_list_servers( @admin_router.get("/servers/{server_id}", response_model=ServerRead) -async def admin_get_server(server_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ServerRead: +async def admin_get_server(server_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ServerRead: """ Retrieve server details for the admin UI. Args: - server_id (int): The ID of the server to retrieve. + server_id (str): The ID of the server to retrieve. db (Session): The database session dependency. user (str): The authenticated user dependency. @@ -120,7 +120,7 @@ async def admin_get_server(server_id: int, db: Session = Depends(get_db), user: try: logger.debug(f"User {user} requested details for server ID {server_id}") server = await server_service.get_server(db, server_id) - return server.dict(by_alias=True) + return server.model_dump(by_alias=True) except ServerNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -154,10 +154,10 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user try: logger.debug(f"User {user} is adding a new server with name: {form['name']}") server = ServerCreate( - name=form["name"], + name=form.get("name"), description=form.get("description"), icon=form.get("icon"), - associated_tools=form.get("associatedTools"), + associated_tools=",".join(form.getlist("associatedTools")), associated_resources=form.get("associatedResources"), associated_prompts=form.get("associatedPrompts"), ) @@ -174,7 +174,7 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user @admin_router.post("/servers/{server_id}/edit") async def admin_edit_server( - server_id: int, + server_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -195,7 +195,7 @@ async def admin_edit_server( - associatedPrompts (optional, comma-separated): Updated list of prompts associated with this server Args: - server_id (int): The ID of the server to edit + server_id (str): The ID of the server to edit request (Request): FastAPI request containing form data db (Session): Database session dependency user (str): Authenticated user dependency @@ -210,7 +210,7 @@ async def admin_edit_server( name=form.get("name"), description=form.get("description"), icon=form.get("icon"), - associated_tools=form.get("associatedTools"), + associated_tools=",".join(form.getlist("associatedTools")), associated_resources=form.get("associatedResources"), associated_prompts=form.get("associatedPrompts"), ) @@ -227,7 +227,7 @@ async def admin_edit_server( @admin_router.post("/servers/{server_id}/toggle") async def admin_toggle_server( - server_id: int, + server_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -241,7 +241,7 @@ async def admin_toggle_server( logs any errors that might occur during the status toggle operation. Args: - server_id (int): The ID of the server whose status to toggle. + server_id (str): The ID of the server whose status to toggle. request (Request): FastAPI request containing form data with the 'activate' field. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -263,7 +263,7 @@ async def admin_toggle_server( @admin_router.post("/servers/{server_id}/delete") -async def admin_delete_server(server_id: int, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> RedirectResponse: +async def admin_delete_server(server_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> RedirectResponse: """ Delete a server via the admin UI. @@ -271,7 +271,7 @@ async def admin_delete_server(server_id: int, request: Request, db: Session = De gracefully and logs any errors that occur during the deletion process. Args: - server_id (int): The ID of the server to delete + server_id (str): The ID of the server to delete request (Request): FastAPI request object (not used but required by route signature). db (Session): Database session dependency user (str): Authenticated user dependency @@ -370,7 +370,7 @@ async def admin_list_gateways( @admin_router.post("/gateways/{gateway_id}/toggle") async def admin_toggle_gateway( - gateway_id: int, + gateway_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -383,7 +383,7 @@ async def admin_toggle_gateway( determine the new status of the gateway. Args: - gateway_id (int): The ID of the gateway to toggle. + gateway_id (str): The ID of the gateway to toggle. request (Request): The FastAPI request object containing form data. db (Session): The database session dependency. user (str): The authenticated user dependency. @@ -433,12 +433,12 @@ async def admin_ui( HTMLResponse: Rendered HTML template for the admin dashboard. """ logger.debug(f"User {user} accessed the admin UI") - servers = [server.dict(by_alias=True) for server in await server_service.list_servers(db, include_inactive=include_inactive)] - tools = [tool.dict(by_alias=True) for tool in await tool_service.list_tools(db, include_inactive=include_inactive)] - resources = [resource.dict(by_alias=True) for resource in await resource_service.list_resources(db, include_inactive=include_inactive)] - prompts = [prompt.dict(by_alias=True) for prompt in await prompt_service.list_prompts(db, include_inactive=include_inactive)] - gateways = [gateway.dict(by_alias=True) for gateway in await gateway_service.list_gateways(db, include_inactive=include_inactive)] - roots = [root.dict(by_alias=True) for root in await root_service.list_roots()] + servers = [server.model_dump(by_alias=True) for server in await server_service.list_servers(db, include_inactive=include_inactive)] + tools = [tool.model_dump(by_alias=True) for tool in await tool_service.list_tools(db, include_inactive=include_inactive)] + resources = [resource.model_dump(by_alias=True) for resource in await resource_service.list_resources(db, include_inactive=include_inactive)] + prompts = [prompt.model_dump(by_alias=True) for prompt in await prompt_service.list_prompts(db, include_inactive=include_inactive)] + gateways = [gateway.model_dump(by_alias=True) for gateway in await gateway_service.list_gateways(db, include_inactive=include_inactive)] + roots = [root.model_dump(by_alias=True) for root in await root_service.list_roots()] root_path = settings.app_root_path response = request.app.state.templates.TemplateResponse( "admin.html", @@ -452,6 +452,7 @@ async def admin_ui( "roots": roots, "include_inactive": include_inactive, "root_path": root_path, + "gateway_tool_name_separator": settings.gateway_tool_name_separator, }, ) @@ -486,7 +487,7 @@ async def admin_list_tools( @admin_router.get("/tools/{tool_id}", response_model=ToolRead) -async def admin_get_tool(tool_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ToolRead: +async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ToolRead: """ Retrieve specific tool details for the admin UI. @@ -495,7 +496,7 @@ async def admin_get_tool(tool_id: int, db: Session = Depends(get_db), user: str viewing and management purposes. Args: - tool_id (int): The ID of the tool to retrieve. + tool_id (str): The ID of the tool to retrieve. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -504,7 +505,7 @@ async def admin_get_tool(tool_id: int, db: Session = Depends(get_db), user: str """ logger.debug(f"User {user} requested details for tool ID {tool_id}") tool = await tool_service.get_tool(db, tool_id) - return tool.dict(by_alias=True) + return tool.model_dump(by_alias=True) @admin_router.post("/tools/") @@ -583,7 +584,7 @@ async def admin_add_tool( @admin_router.post("/tools/{tool_id}/edit/") @admin_router.post("/tools/{tool_id}/edit") async def admin_edit_tool( - tool_id: int, + tool_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -611,7 +612,7 @@ async def admin_edit_tool( snake-case keys expected by the schemas. Args: - tool_id (int): The ID of the tool to edit. + tool_id (str): The ID of the tool to edit. request (Request): FastAPI request containing form data. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -639,7 +640,7 @@ async def admin_edit_tool( "auth_header_key": form.get("auth_header_key", ""), "auth_header_value": form.get("auth_header_value", ""), } - logger.info(f"Tool update data built: {tool_data}") + logger.debug(f"Tool update data built: {tool_data}") tool = ToolUpdate(**tool_data) try: await tool_service.update_tool(db, tool_id, tool) @@ -653,7 +654,7 @@ async def admin_edit_tool( @admin_router.post("/tools/{tool_id}/delete") -async def admin_delete_tool(tool_id: int, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> RedirectResponse: +async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> RedirectResponse: """ Delete a tool via the admin UI. @@ -662,7 +663,7 @@ async def admin_delete_tool(tool_id: int, request: Request, db: Session = Depend and the user must be authenticated to access this route. Args: - tool_id (int): The ID of the tool to delete. + tool_id (str): The ID of the tool to delete. request (Request): FastAPI request object (not used directly, but required by route signature). db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -680,7 +681,7 @@ async def admin_delete_tool(tool_id: int, request: Request, db: Session = Depend @admin_router.post("/tools/{tool_id}/toggle") async def admin_toggle_tool( - tool_id: int, + tool_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -694,7 +695,7 @@ async def admin_toggle_tool( logs any errors that might occur during the status toggle operation. Args: - tool_id (int): The ID of the tool whose status to toggle. + tool_id (str): The ID of the tool whose status to toggle. request (Request): FastAPI request containing form data with the 'activate' field. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -716,7 +717,7 @@ async def admin_toggle_tool( @admin_router.get("/gateways/{gateway_id}", response_model=GatewayRead) -async def admin_get_gateway(gateway_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> GatewayRead: +async def admin_get_gateway(gateway_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> GatewayRead: """Get gateway details for the admin UI. Args: @@ -782,7 +783,7 @@ async def admin_add_gateway(request: Request, db: Session = Depends(get_db), use @admin_router.post("/gateways/{gateway_id}/edit") async def admin_edit_gateway( - gateway_id: int, + gateway_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -824,7 +825,7 @@ async def admin_edit_gateway( @admin_router.post("/gateways/{gateway_id}/delete") -async def admin_delete_gateway(gateway_id: int, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> RedirectResponse: +async def admin_delete_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> RedirectResponse: """ Delete a gateway via the admin UI. @@ -833,7 +834,7 @@ async def admin_delete_gateway(gateway_id: int, request: Request, db: Session = operation for auditing purposes. Args: - gateway_id (int): The ID of the gateway to delete. + gateway_id (str): The ID of the gateway to delete. request (Request): FastAPI request object (not used directly but required by the route signature). db (Session): Database session dependency. user (str): Authenticated user dependency. diff --git a/mcpgateway/config.py b/mcpgateway/config.py index 0cfec71f4..474a90a5f 100644 --- a/mcpgateway/config.py +++ b/mcpgateway/config.py @@ -202,6 +202,8 @@ def _parse_federation_peers(cls, v): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore") + gateway_tool_name_separator: str = "-" + @property def api_key(self) -> str: """Generate API key from auth credentials. diff --git a/mcpgateway/db.py b/mcpgateway/db.py index 79155867c..d64cf52f6 100644 --- a/mcpgateway/db.py +++ b/mcpgateway/db.py @@ -16,6 +16,7 @@ """ import re +import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional @@ -31,7 +32,9 @@ String, Table, Text, + UniqueConstraint, create_engine, + event, func, make_url, select, @@ -46,9 +49,11 @@ relationship, sessionmaker, ) +from sqlalchemy.orm.attributes import get_history from mcpgateway.config import settings from mcpgateway.types import ResourceContent +from mcpgateway.utils.create_slug import slugify # --------------------------------------------------------------------------- # 1. Parse the URL so we can inspect backend ("postgresql", "sqlite", …) @@ -109,8 +114,8 @@ class Base(DeclarativeBase): # tool_gateway_table = Table( # "tool_gateway_association", # Base.metadata, -# Column("tool_id", Integer, ForeignKey("tools.id"), primary_key=True), -# Column("gateway_id", Integer, ForeignKey("gateways.id"), primary_key=True), +# Column("tool_id", String, ForeignKey("tools.id"), primary_key=True), +# Column("gateway_id", String, ForeignKey("gateways.id"), primary_key=True), # ) # # Association table for resources and gateways (federation) @@ -118,7 +123,7 @@ class Base(DeclarativeBase): # "resource_gateway_association", # Base.metadata, # Column("resource_id", Integer, ForeignKey("resources.id"), primary_key=True), -# Column("gateway_id", Integer, ForeignKey("gateways.id"), primary_key=True), +# Column("gateway_id", String, ForeignKey("gateways.id"), primary_key=True), # ) # # Association table for prompts and gateways (federation) @@ -126,22 +131,22 @@ class Base(DeclarativeBase): # "prompt_gateway_association", # Base.metadata, # Column("prompt_id", Integer, ForeignKey("prompts.id"), primary_key=True), -# Column("gateway_id", Integer, ForeignKey("gateways.id"), primary_key=True), +# Column("gateway_id", String, ForeignKey("gateways.id"), primary_key=True), # ) # Association table for servers and tools server_tool_association = Table( "server_tool_association", Base.metadata, - Column("server_id", Integer, ForeignKey("servers.id"), primary_key=True), - Column("tool_id", Integer, ForeignKey("tools.id"), primary_key=True), + Column("server_id", String, ForeignKey("servers.id"), primary_key=True), + Column("tool_id", String, ForeignKey("tools.id"), primary_key=True), ) # Association table for servers and resources server_resource_association = Table( "server_resource_association", Base.metadata, - Column("server_id", Integer, ForeignKey("servers.id"), primary_key=True), + Column("server_id", String, ForeignKey("servers.id"), primary_key=True), Column("resource_id", Integer, ForeignKey("resources.id"), primary_key=True), ) @@ -149,7 +154,7 @@ class Base(DeclarativeBase): server_prompt_association = Table( "server_prompt_association", Base.metadata, - Column("server_id", Integer, ForeignKey("servers.id"), primary_key=True), + Column("server_id", String, ForeignKey("servers.id"), primary_key=True), Column("prompt_id", Integer, ForeignKey("prompts.id"), primary_key=True), ) @@ -172,7 +177,7 @@ class ToolMetric(Base): __tablename__ = "tool_metrics" id: Mapped[int] = mapped_column(primary_key=True) - tool_id: Mapped[int] = mapped_column(Integer, ForeignKey("tools.id"), nullable=False) + tool_id: Mapped[str] = mapped_column(String, ForeignKey("tools.id"), nullable=False) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) response_time: Mapped[float] = mapped_column(Float, nullable=False) is_success: Mapped[bool] = mapped_column(Boolean, nullable=False) @@ -214,7 +219,7 @@ class ServerMetric(Base): Attributes: id (int): Primary key. - server_id (int): Foreign key linking to the server. + server_id (str): Foreign key linking to the server. timestamp (datetime): The time when the invocation occurred. response_time (float): The response time in seconds. is_success (bool): True if the invocation succeeded, False otherwise. @@ -224,7 +229,7 @@ class ServerMetric(Base): __tablename__ = "server_metrics" id: Mapped[int] = mapped_column(primary_key=True) - server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False) + server_id: Mapped[str] = mapped_column(String, ForeignKey("servers.id"), nullable=False) timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) response_time: Mapped[float] = mapped_column(Float, nullable=False) is_success: Mapped[bool] = mapped_column(Boolean, nullable=False) @@ -294,8 +299,9 @@ class Tool(Base): __tablename__ = "tools" - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(unique=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) + original_name: Mapped[str] = mapped_column(String, nullable=False) + original_name_slug: Mapped[str] = mapped_column(String, nullable=False) url: Mapped[str] = mapped_column(String, nullable=True) description: Mapped[Optional[str]] integration_type: Mapped[str] = mapped_column(default="MCP") @@ -312,8 +318,9 @@ class Tool(Base): auth_value: Mapped[Optional[str]] = mapped_column(default=None) # Federation relationship with a local gateway - gateway_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gateways.id")) - gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="tools") + gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) + # gateway_slug: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.slug")) + gateway: Mapped["Gateway"] = relationship("Gateway", primaryjoin="Tool.gateway_id == Gateway.id", foreign_keys=[gateway_id], back_populates="tools") # federated_with = relationship("Gateway", secondary=tool_gateway_table, back_populates="federated_tools") # Many-to-many relationship with Servers @@ -322,6 +329,73 @@ class Tool(Base): # Relationship with ToolMetric records metrics: Mapped[List["ToolMetric"]] = relationship("ToolMetric", back_populates="tool", cascade="all, delete-orphan") + # @property + # def gateway_slug(self) -> str: + # return self.gateway.slug + + _computed_name = Column("name", String, unique=True) # Stored column + + @hybrid_property + def name(self): + """Return the display/lookup name. + + Returns: + str: Name to display + """ + if self._computed_name: # pylint: disable=no-member + return self._computed_name # orm column, resolved at runtime + + original_slug = slugify(self.original_name) # pylint: disable=no-member + + # Gateway present → prepend its slug and the configured separator + if self.gateway_id: # pylint: disable=no-member + gateway_slug = slugify(self.gateway.name) # pylint: disable=no-member + return f"{gateway_slug}{settings.gateway_tool_name_separator}{original_slug}" + + # No gateway → only the original name slug + return original_slug + + @name.setter + def name(self, value): + """Store an explicit value that overrides the calculated one. + + Args: + value (str): Value to set to _computed_name + """ + self._computed_name = value + + @name.expression + def name(cls): # pylint: disable=no-self-argument + """ + SQL expression used when the hybrid appears in a filter/order_by. + Simply forwards to the ``_computed_name`` column; the Python-side + reconstruction above is not needed on the SQL side. + + Returns: + str: computed name for SQL use + """ + return cls._computed_name + + __table_args__ = (UniqueConstraint("gateway_id", "original_name", name="uq_gateway_id__original_name"),) + + @hybrid_property + def gateway_slug(self): + """Always returns the current slug from the related Gateway + + Returns: + str: slug for Python use + """ + return self.gateway.slug if self.gateway else None + + @gateway_slug.expression + def gateway_slug(cls): # pylint: disable=no-self-argument + """For database queries - auto-joins to get current slug + + Returns: + str: slug for SQL use + """ + return select(Gateway.slug).where(Gateway.id == cls.gateway_id).scalar_subquery() + @hybrid_property def execution_count(self) -> int: """ @@ -491,7 +565,7 @@ class Resource(Base): # Subscription tracking subscriptions: Mapped[List["ResourceSubscription"]] = relationship("ResourceSubscription", back_populates="resource", cascade="all, delete-orphan") - gateway_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gateways.id")) + gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="resources") # federated_with = relationship("Gateway", secondary=resource_gateway_table, back_populates="federated_resources") @@ -672,7 +746,7 @@ class Prompt(Base): is_active: Mapped[bool] = mapped_column(default=True) metrics: Mapped[List["PromptMetric"]] = relationship("PromptMetric", back_populates="prompt", cascade="all, delete-orphan") - gateway_id: Mapped[Optional[int]] = mapped_column(ForeignKey("gateways.id")) + gateway_id: Mapped[Optional[str]] = mapped_column(ForeignKey("gateways.id")) gateway: Mapped["Gateway"] = relationship("Gateway", back_populates="prompts") # federated_with = relationship("Gateway", secondary=prompt_gateway_table, back_populates="federated_prompts") @@ -812,7 +886,7 @@ class Server(Base): __tablename__ = "servers" - id: Mapped[int] = mapped_column(primary_key=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) name: Mapped[str] = mapped_column(unique=True) description: Mapped[Optional[str]] icon: Mapped[Optional[str]] @@ -929,9 +1003,10 @@ class Gateway(Base): __tablename__ = "gateways" - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(unique=True) - url: Mapped[str] + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: uuid.uuid4().hex) + name: Mapped[str] = mapped_column(String, nullable=False) + slug: Mapped[str] = mapped_column(String, nullable=False, unique=True) + url: Mapped[str] = mapped_column(String, unique=True) description: Mapped[Optional[str]] transport: Mapped[str] = mapped_column(default="SSE") capabilities: Mapped[Dict[str, Any]] = mapped_column(JSON) @@ -941,7 +1016,7 @@ class Gateway(Base): last_seen: Mapped[Optional[datetime]] # Relationship with local tools this gateway provides - tools: Mapped[List["Tool"]] = relationship(back_populates="gateway", cascade="all, delete-orphan") + tools: Mapped[List["Tool"]] = relationship(back_populates="gateway", foreign_keys="Tool.gateway_id", cascade="all, delete-orphan") # Relationship with local prompts this gateway provides prompts: Mapped[List["Prompt"]] = relationship(back_populates="gateway", cascade="all, delete-orphan") @@ -963,6 +1038,44 @@ class Gateway(Base): auth_value: Mapped[Optional[Dict[str, str]]] = mapped_column(JSON) +@event.listens_for(Gateway, "after_update") +def update_tool_names_on_gateway_update(_mapper, connection, target): + """ + If a Gateway's name is updated, efficiently update all of its + child Tools' names with a single SQL statement. + + Args: + _mapper: Mapper + connection: Connection + target: Target + """ + # 1. Check if the 'name' field was actually part of the update. + # This is a concise way to see if the value has changed. + if not get_history(target, "name").has_changes(): + return + + print(f"Gateway name changed for ID {target.id}. Issuing bulk update for tools.") + + # 2. Get a reference to the underlying database table for Tools + tools_table = Tool.__table__ + + # 3. Prepare the new values + new_gateway_slug = slugify(target.name) + separator = settings.gateway_tool_name_separator + + # 4. Construct a single, powerful UPDATE statement using SQLAlchemy Core. + # This is highly efficient as it all happens in the database. + stmt = ( + tools_table.update() + .where(tools_table.c.gateway_id == target.id) + .values(name=new_gateway_slug + separator + tools_table.c.original_name_slug) + .execution_options(synchronize_session=False) # Important for bulk updates + ) + + # 5. Execute the statement using the connection from the ongoing transaction. + connection.execute(stmt) + + class SessionRecord(Base): """ORM model for sessions from SSE client.""" diff --git a/mcpgateway/federation/forward.py b/mcpgateway/federation/forward.py index 214853b64..860da012e 100644 --- a/mcpgateway/federation/forward.py +++ b/mcpgateway/federation/forward.py @@ -184,7 +184,7 @@ async def forward_resource_request(self, db: Session, uri: str) -> Tuple[Union[s async def _forward_to_gateway( self, db: Session, - gateway_id: int, + gateway_id: str, method: str, params: Optional[Dict[str, Any]] = None, ) -> Any: diff --git a/mcpgateway/federation/manager.py b/mcpgateway/federation/manager.py index 1d8224b63..a32910a91 100644 --- a/mcpgateway/federation/manager.py +++ b/mcpgateway/federation/manager.py @@ -174,7 +174,7 @@ async def register_gateway(self, db: Session, url: str, name: Optional[str] = No db.rollback() raise FederationError(f"Failed to register gateway: {str(e)}") - async def unregister_gateway(self, db: Session, gateway_id: int) -> None: + async def unregister_gateway(self, db: Session, gateway_id: str) -> None: """Unregister a gateway. Args: @@ -211,7 +211,7 @@ async def unregister_gateway(self, db: Session, gateway_id: int) -> None: db.rollback() raise FederationError(f"Failed to unregister gateway: {str(e)}") - async def get_gateway_tools(self, db: Session, gateway_id: int) -> List[Tool]: + async def get_gateway_tools(self, db: Session, gateway_id: str) -> List[Tool]: """Get tools provided by a gateway. Args: @@ -236,7 +236,7 @@ async def get_gateway_tools(self, db: Session, gateway_id: int) -> List[Tool]: except Exception as e: raise FederationError(f"Failed to get tools from {gateway.name}: {str(e)}") - async def get_gateway_resources(self, db: Session, gateway_id: int) -> List[Resource]: + async def get_gateway_resources(self, db: Session, gateway_id: str) -> List[Resource]: """Get resources provided by a gateway. Args: @@ -261,7 +261,7 @@ async def get_gateway_resources(self, db: Session, gateway_id: int) -> List[Reso except Exception as e: raise FederationError(f"Failed to get resources from {gateway.name}: {str(e)}") - async def get_gateway_prompts(self, db: Session, gateway_id: int) -> List[Prompt]: + async def get_gateway_prompts(self, db: Session, gateway_id: str) -> List[Prompt]: """Get prompts provided by a gateway. Args: diff --git a/mcpgateway/main.py b/mcpgateway/main.py index bd304637f..d60b5413a 100644 --- a/mcpgateway/main.py +++ b/mcpgateway/main.py @@ -561,12 +561,12 @@ async def list_servers( @server_router.get("/{server_id}", response_model=ServerRead) -async def get_server(server_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ServerRead: +async def get_server(server_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> ServerRead: """ Retrieves a server by its ID. Args: - server_id (int): The ID of the server to retrieve. + server_id (str): The ID of the server to retrieve. db (Session): The database session used to interact with the data store. user (str): The authenticated user making the request. @@ -615,7 +615,7 @@ async def create_server( @server_router.put("/{server_id}", response_model=ServerRead) async def update_server( - server_id: int, + server_id: str, server: ServerUpdate, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -624,7 +624,7 @@ async def update_server( Updates the information of an existing server. Args: - server_id (int): The ID of the server to update. + server_id (str): The ID of the server to update. server (ServerUpdate): The updated server data. db (Session): The database session used to interact with the data store. user (str): The authenticated user making the request. @@ -648,7 +648,7 @@ async def update_server( @server_router.post("/{server_id}/toggle", response_model=ServerRead) async def toggle_server_status( - server_id: int, + server_id: str, activate: bool = True, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -657,7 +657,7 @@ async def toggle_server_status( Toggles the status of a server (activate or deactivate). Args: - server_id (int): The ID of the server to toggle. + server_id (str): The ID of the server to toggle. activate (bool): Whether to activate or deactivate the server. db (Session): The database session used to interact with the data store. user (str): The authenticated user making the request. @@ -678,12 +678,12 @@ async def toggle_server_status( @server_router.delete("/{server_id}", response_model=Dict[str, str]) -async def delete_server(server_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: +async def delete_server(server_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: """ Deletes a server by its ID. Args: - server_id (int): The ID of the server to delete. + server_id (str): The ID of the server to delete. db (Session): The database session used to interact with the data store. user (str): The authenticated user making the request. @@ -707,13 +707,13 @@ async def delete_server(server_id: int, db: Session = Depends(get_db), user: str @server_router.get("/{server_id}/sse") -async def sse_endpoint(request: Request, server_id: int, user: str = Depends(require_auth)): +async def sse_endpoint(request: Request, server_id: str, user: str = Depends(require_auth)): """ Establishes a Server-Sent Events (SSE) connection for real-time updates about a server. Args: request (Request): The incoming request. - server_id (int): The ID of the server for which updates are received. + server_id (str): The ID of the server for which updates are received. user (str): The authenticated user making the request. Returns: @@ -744,13 +744,13 @@ async def sse_endpoint(request: Request, server_id: int, user: str = Depends(req @server_router.post("/{server_id}/message") -async def message_endpoint(request: Request, server_id: int, user: str = Depends(require_auth)): +async def message_endpoint(request: Request, server_id: str, user: str = Depends(require_auth)): """ Handles incoming messages for a specific server. Args: request (Request): The incoming message request. - server_id (int): The ID of the server receiving the message. + server_id (str): The ID of the server receiving the message. user (str): The authenticated user making the request. Returns: @@ -786,7 +786,7 @@ async def message_endpoint(request: Request, server_id: int, user: str = Depends @server_router.get("/{server_id}/tools", response_model=List[ToolRead]) async def server_get_tools( - server_id: int, + server_id: str, include_inactive: bool = False, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -799,7 +799,7 @@ async def server_get_tools( that have been deactivated but not deleted from the system. Args: - server_id (int): ID of the server + server_id (str): ID of the server include_inactive (bool): Whether to include inactive tools in the results. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -814,7 +814,7 @@ async def server_get_tools( @server_router.get("/{server_id}/resources", response_model=List[ResourceRead]) async def server_get_resources( - server_id: int, + server_id: str, include_inactive: bool = False, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -827,7 +827,7 @@ async def server_get_resources( to view or manage resources that have been deactivated but not deleted. Args: - server_id (int): ID of the server + server_id (str): ID of the server include_inactive (bool): Whether to include inactive resources in the results. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -842,7 +842,7 @@ async def server_get_resources( @server_router.get("/{server_id}/prompts", response_model=List[PromptRead]) async def server_get_prompts( - server_id: int, + server_id: str, include_inactive: bool = False, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -855,7 +855,7 @@ async def server_get_prompts( prompts that have been deactivated but not deleted from the system. Args: - server_id (int): ID of the server + server_id (str): ID of the server include_inactive (bool): Whether to include inactive prompts in the results. db (Session): Database session dependency. user (str): Authenticated user dependency. @@ -937,7 +937,7 @@ async def create_tool(tool: ToolCreate, db: Session = Depends(get_db), user: str @tool_router.get("/{tool_id}", response_model=Union[ToolRead, Dict]) async def get_tool( - tool_id: int, + tool_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth), apijsonpath: JsonPathModifier = Body(None), @@ -973,7 +973,7 @@ async def get_tool( @tool_router.put("/{tool_id}", response_model=ToolRead) async def update_tool( - tool_id: int, + tool_id: str, tool: ToolUpdate, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -982,7 +982,7 @@ async def update_tool( Updates an existing tool with new data. Args: - tool_id (int): The ID of the tool to update. + tool_id (str): The ID of the tool to update. tool (ToolUpdate): The updated tool information. db (Session): The database session dependency. user (str): The authenticated user making the request. @@ -1001,12 +1001,12 @@ async def update_tool( @tool_router.delete("/{tool_id}") -async def delete_tool(tool_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: +async def delete_tool(tool_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: """ Permanently deletes a tool by ID. Args: - tool_id (int): The ID of the tool to delete. + tool_id (str): The ID of the tool to delete. db (Session): The database session dependency. user (str): The authenticated user making the request. @@ -1026,7 +1026,7 @@ async def delete_tool(tool_id: int, db: Session = Depends(get_db), user: str = D @tool_router.post("/{tool_id}/toggle") async def toggle_tool_status( - tool_id: int, + tool_id: str, activate: bool = True, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -1035,7 +1035,7 @@ async def toggle_tool_status( Activates or deactivates a tool. Args: - tool_id (int): The ID of the tool to toggle. + tool_id (str): The ID of the tool to toggle. activate (bool): Whether to activate (`True`) or deactivate (`False`) the tool. db (Session): The database session dependency. user (str): The authenticated user making the request. @@ -1480,7 +1480,7 @@ async def delete_prompt(name: str, db: Session = Depends(get_db), user: str = De ################ @gateway_router.post("/{gateway_id}/toggle") async def toggle_gateway_status( - gateway_id: int, + gateway_id: str, activate: bool = True, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -1489,7 +1489,7 @@ async def toggle_gateway_status( Toggle the activation status of a gateway. Args: - gateway_id (int): Numeric ID of the gateway to toggle. + gateway_id (str): String ID of the gateway to toggle. activate (bool): ``True`` to activate, ``False`` to deactivate. db (Session): Active SQLAlchemy session. user (str): Authenticated username. @@ -1562,16 +1562,15 @@ async def register_gateway( except Exception as ex: if isinstance(ex, GatewayConnectionError): return JSONResponse(content={"message": "Unable to connect to gateway"}, status_code=502) - elif isinstance(ex, ValueError): + if isinstance(ex, ValueError): return JSONResponse(content={"message": "Unable to process input"}, status_code=400) - elif isinstance(ex, RuntimeError): + if isinstance(ex, RuntimeError): return JSONResponse(content={"message": "Error during execution"}, status_code=500) - else: - return JSONResponse(content={"message": "Unexpected error"}, status_code=500) + return JSONResponse(content={"message": "Unexpected error"}, status_code=500) @gateway_router.get("/{gateway_id}", response_model=GatewayRead) -async def get_gateway(gateway_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> GatewayRead: +async def get_gateway(gateway_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> GatewayRead: """ Retrieve a gateway by ID. @@ -1589,7 +1588,7 @@ async def get_gateway(gateway_id: int, db: Session = Depends(get_db), user: str @gateway_router.put("/{gateway_id}", response_model=GatewayRead) async def update_gateway( - gateway_id: int, + gateway_id: str, gateway: GatewayUpdate, db: Session = Depends(get_db), user: str = Depends(require_auth), @@ -1611,7 +1610,7 @@ async def update_gateway( @gateway_router.delete("/{gateway_id}") -async def delete_gateway(gateway_id: int, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: +async def delete_gateway(gateway_id: str, db: Session = Depends(get_db), user: str = Depends(require_auth)) -> Dict[str, str]: """ Delete a gateway by ID. @@ -1771,7 +1770,7 @@ async def handle_rpc(request: Request, db: Session = Depends(get_db), user: str result = {} else: try: - result = await tool_service.invoke_tool(db, method, params) + result = await tool_service.invoke_tool(db=db, name=method, arguments=params) if hasattr(result, "model_dump"): result = result.model_dump(by_alias=True, exclude_none=True) except ValueError: diff --git a/mcpgateway/schemas.py b/mcpgateway/schemas.py index e8b0c80c9..db335a21b 100644 --- a/mcpgateway/schemas.py +++ b/mcpgateway/schemas.py @@ -288,7 +288,7 @@ class ToolCreate(BaseModelWithConfig): ) jsonpath_filter: Optional[str] = Field(default="", description="JSON modification filter") auth: Optional[AuthenticationValues] = Field(None, description="Authentication credentials (Basic or Bearer Token or custom headers) if required") - gateway_id: Optional[int] = Field(None, description="id of gateway for the tool") + gateway_id: Optional[str] = Field(None, description="id of gateway for the tool") @root_validator(pre=True) def assemble_auth(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -346,7 +346,7 @@ class ToolUpdate(BaseModelWithConfig): input_schema: Optional[Dict[str, Any]] = Field(None, description="JSON Schema for validating tool parameters") jsonpath_filter: Optional[str] = Field(None, description="JSON path filter for rpc tool calls") auth: Optional[AuthenticationValues] = Field(None, description="Authentication credentials (Basic or Bearer Token or custom headers) if required") - gateway_id: Optional[int] = Field(None, description="id of gateway for the tool") + gateway_id: Optional[str] = Field(None, description="id of gateway for the tool") @root_validator(pre=True) def assemble_auth(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -403,8 +403,8 @@ class ToolRead(BaseModelWithConfig): - Request type and authentication settings. """ - id: int - name: str + id: str + original_name: str url: Optional[str] description: Optional[str] request_type: str @@ -416,9 +416,12 @@ class ToolRead(BaseModelWithConfig): created_at: datetime updated_at: datetime is_active: bool - gateway_id: Optional[int] + gateway_id: Optional[str] execution_count: int metrics: ToolMetrics + name: str + gateway_slug: str + original_name_slug: str class Config(BaseModelWithConfig.Config): """ @@ -875,7 +878,7 @@ class GatewayRead(BaseModelWithConfig): - Authentication header value: for headers auth """ - id: int = Field(None, description="Unique ID of the gateway") + id: str = Field(None, description="Unique ID of the gateway") name: str = Field(..., description="Unique name for the gateway") url: str = Field(..., description="Gateway endpoint URL") description: Optional[str] = Field(None, description="Gateway description") @@ -897,6 +900,8 @@ class GatewayRead(BaseModelWithConfig): auth_header_key: Optional[str] = Field(None, description="key for custom headers authentication") auth_header_value: Optional[str] = Field(None, description="vallue for custom headers authentication") + slug: str = Field(None, description="Slug for gateway endpoint URL") + # This will be the main method to automatically populate fields @model_validator(mode="after") def _populate_auth(cls, values: Dict[str, Any]) -> Dict[str, Any]: @@ -1175,14 +1180,14 @@ class ServerRead(BaseModelWithConfig): - Metrics: Aggregated metrics for the server invocations. """ - id: int + id: str name: str description: Optional[str] icon: Optional[str] created_at: datetime updated_at: datetime is_active: bool - associated_tools: List[int] = [] + associated_tools: List[str] = [] associated_resources: List[int] = [] associated_prompts: List[int] = [] metrics: ServerMetrics diff --git a/mcpgateway/services/gateway_service.py b/mcpgateway/services/gateway_service.py index 206fc226f..b3455f331 100644 --- a/mcpgateway/services/gateway_service.py +++ b/mcpgateway/services/gateway_service.py @@ -34,6 +34,7 @@ from mcpgateway.db import Tool as DbTool from mcpgateway.schemas import GatewayCreate, GatewayRead, GatewayUpdate, ToolCreate from mcpgateway.services.tool_service import ToolService +from mcpgateway.utils.create_slug import slugify from mcpgateway.utils.services_auth import decode_auth try: @@ -187,17 +188,13 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway auth_type = getattr(gateway, "auth_type", None) auth_value = getattr(gateway, "auth_value", {}) - capabilities, tools = await self._initialize_gateway(str(gateway.url), auth_value, gateway.transport) - - all_names = [td.name for td in tools] - - existing_tools = db.execute(select(DbTool).where(DbTool.name.in_(all_names))).scalars().all() - existing_tool_names = [tool.name for tool in existing_tools] + capabilities, tools = await self._initialize_gateway(gateway.url, auth_value, gateway.transport) tools = [ DbTool( - name=tool.name, - url=str(gateway.url), + original_name=tool.name, + original_name_slug=slugify(tool.name), + url=gateway.url, description=tool.description, integration_type=tool.integration_type, request_type=tool.request_type, @@ -210,21 +207,18 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway for tool in tools ] - existing_tools = [tool for tool in tools if tool.name in existing_tool_names] - new_tools = [tool for tool in tools if tool.name not in existing_tool_names] - # Create DB model db_gateway = DbGateway( name=gateway.name, - url=str(gateway.url), + slug=slugify(gateway.name), + url=gateway.url, description=gateway.description, transport=gateway.transport, capabilities=capabilities, last_seen=datetime.now(timezone.utc), auth_type=auth_type, auth_value=auth_value, - tools=new_tools, - # federated_tools=existing_tools + new_tools + tools=tools, ) # Add to DB @@ -270,7 +264,7 @@ async def list_gateways(self, db: Session, include_inactive: bool = False) -> Li gateways = db.execute(query).scalars().all() return [GatewayRead.model_validate(g) for g in gateways] - async def update_gateway(self, db: Session, gateway_id: int, gateway_update: GatewayUpdate) -> GatewayRead: + async def update_gateway(self, db: Session, gateway_id: str, gateway_update: GatewayUpdate) -> GatewayRead: """Update a gateway. Args: @@ -309,8 +303,9 @@ async def update_gateway(self, db: Session, gateway_id: int, gateway_update: Gat # Update fields if provided if gateway_update.name is not None: gateway.name = gateway_update.name + gateway.slug = slugify(gateway_update.name) if gateway_update.url is not None: - gateway.url = str(gateway_update.url) + gateway.url = gateway_update.url if gateway_update.description is not None: gateway.description = gateway_update.description if gateway_update.transport is not None: @@ -326,9 +321,31 @@ async def update_gateway(self, db: Session, gateway_id: int, gateway_update: Gat # Try to reinitialize connection if URL changed if gateway_update.url is not None: try: - capabilities, _ = await self._initialize_gateway(gateway.url, gateway.auth_value, gateway.transport) + capabilities, tools = await self._initialize_gateway(gateway.url, gateway.auth_value, gateway.transport) + new_tool_names = [tool.name for tool in tools] + + for tool in tools: + existing_tool = db.execute(select(DbTool).where(DbTool.original_name == tool.name).where(DbTool.gateway_id == gateway_id)).scalar_one_or_none() + if not existing_tool: + gateway.tools.append( + DbTool( + original_name=tool.name, + original_name_slug=slugify(tool.name), + url=gateway.url, + description=tool.description, + integration_type=tool.integration_type, + request_type=tool.request_type, + headers=tool.headers, + input_schema=tool.input_schema, + jsonpath_filter=tool.jsonpath_filter, + auth_type=gateway.auth_type, + auth_value=gateway.auth_value, + ) + ) + gateway.capabilities = capabilities - gateway.last_seen = datetime.utcnow() + gateway.tools = [tool for tool in gateway.tools if tool.original_name in new_tool_names] # keep only still-valid rows + gateway.last_seen = datetime.now(timezone.utc) # Update tracking with new URL self._active_gateways.discard(gateway.url) @@ -336,7 +353,7 @@ async def update_gateway(self, db: Session, gateway_id: int, gateway_update: Gat except Exception as e: logger.warning(f"Failed to initialize updated gateway: {e}") - gateway.updated_at = datetime.utcnow() + gateway.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(gateway) @@ -350,7 +367,7 @@ async def update_gateway(self, db: Session, gateway_id: int, gateway_update: Gat db.rollback() raise GatewayError(f"Failed to update gateway: {str(e)}") - async def get_gateway(self, db: Session, gateway_id: int, include_inactive: bool = False) -> GatewayRead: + async def get_gateway(self, db: Session, gateway_id: str, include_inactive: bool = False) -> GatewayRead: """Get a specific gateway by ID. Args: @@ -373,7 +390,7 @@ async def get_gateway(self, db: Session, gateway_id: int, include_inactive: bool return GatewayRead.model_validate(gateway) - async def toggle_gateway_status(self, db: Session, gateway_id: int, activate: bool) -> GatewayRead: + async def toggle_gateway_status(self, db: Session, gateway_id: str, activate: bool) -> GatewayRead: """Toggle gateway active status. Args: @@ -396,7 +413,7 @@ async def toggle_gateway_status(self, db: Session, gateway_id: int, activate: bo # Update status if it's different if gateway.is_active != activate: gateway.is_active = activate - gateway.updated_at = datetime.utcnow() + gateway.updated_at = datetime.now(timezone.utc) # Update tracking if activate: @@ -404,8 +421,31 @@ async def toggle_gateway_status(self, db: Session, gateway_id: int, activate: bo # Try to initialize if activating try: capabilities, tools = await self._initialize_gateway(gateway.url, gateway.auth_value, gateway.transport) - gateway.capabilities = capabilities.dict() - gateway.last_seen = datetime.utcnow() + new_tool_names = [tool.name for tool in tools] + + for tool in tools: + existing_tool = db.execute(select(DbTool).where(DbTool.original_name == tool.name).where(DbTool.gateway_id == gateway_id)).scalar_one_or_none() + if not existing_tool: + gateway.tools.append( + DbTool( + original_name=tool.name, + original_name_slug=slugify(tool.name), + url=gateway.url, + description=tool.description, + integration_type=tool.integration_type, + request_type=tool.request_type, + headers=tool.headers, + input_schema=tool.input_schema, + jsonpath_filter=tool.jsonpath_filter, + auth_type=gateway.auth_type, + auth_value=gateway.auth_value, + ) + ) + + gateway.capabilities = capabilities + gateway.tools = [tool for tool in gateway.tools if tool.original_name in new_tool_names] # keep only still-valid rows + + gateway.last_seen = datetime.now(timezone.utc) except Exception as e: logger.warning(f"Failed to initialize reactivated gateway: {e}") else: @@ -448,11 +488,11 @@ async def _notify_gateway_updated(self, gateway: DbGateway) -> None: "description": gateway.description, "is_active": gateway.is_active, }, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } await self._publish_event(event) - async def delete_gateway(self, db: Session, gateway_id: int) -> None: + async def delete_gateway(self, db: Session, gateway_id: str) -> None: """Permanently delete a gateway. Args: @@ -518,7 +558,7 @@ async def forward_request(self, gateway: DbGateway, method: str, params: Optiona result = response.json() # Update last seen timestamp - gateway.last_seen = datetime.utcnow() + gateway.last_seen = datetime.now(timezone.utc) if "error" in result: raise GatewayError(f"Gateway error: {result['error'].get('message')}") @@ -581,13 +621,13 @@ async def check_health_of_gateways(self, gateways: List[DbGateway]) -> bool: # This will raise immediately if status is 4xx/5xx response.raise_for_status() elif (gateway.transport).lower() == "streamablehttp": - async with streamablehttp_client(url=gateway.url, headers=headers, timeout=settings.health_check_timeout) as (read_stream, write_stream, get_session_id): + async with streamablehttp_client(url=gateway.url, headers=headers, timeout=settings.health_check_timeout) as (read_stream, write_stream, _get_session_id): async with ClientSession(read_stream, write_stream) as session: # Initialize the session response = await session.initialize() # Mark successful check - gateway.last_seen = datetime.utcnow() + gateway.last_seen = datetime.now(timezone.utc) except Exception: await self._handle_gateway_failure(gateway) @@ -705,7 +745,7 @@ async def connect_to_streamablehttp_server(server_url: str, authentication: Opti decoded_auth = decode_auth(authentication) # Use async with for both streamablehttp_client and ClientSession - async with streamablehttp_client(url=server_url, headers=decoded_auth) as (read_stream, write_stream, get_session_id): + async with streamablehttp_client(url=server_url, headers=decoded_auth) as (read_stream, write_stream, _get_session_id): async with ClientSession(read_stream, write_stream) as session: # Initialize the session response = await session.initialize() @@ -723,6 +763,8 @@ async def connect_to_streamablehttp_server(server_url: str, authentication: Opti return capabilities, tools + capabilities = {} + tools = [] if transport.lower() == "sse": capabilities, tools = await connect_to_sse_server(url, authentication) elif transport.lower() == "streamablehttp": @@ -831,7 +873,7 @@ async def _notify_gateway_added(self, gateway: DbGateway) -> None: "description": gateway.description, "is_active": gateway.is_active, }, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } await self._publish_event(event) @@ -850,7 +892,7 @@ async def _notify_gateway_activated(self, gateway: DbGateway) -> None: "url": gateway.url, "is_active": True, }, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } await self._publish_event(event) @@ -869,7 +911,7 @@ async def _notify_gateway_deactivated(self, gateway: DbGateway) -> None: "url": gateway.url, "is_active": False, }, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } await self._publish_event(event) @@ -883,7 +925,7 @@ async def _notify_gateway_deleted(self, gateway_info: Dict[str, Any]) -> None: event = { "type": "gateway_deleted", "data": gateway_info, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } await self._publish_event(event) @@ -897,7 +939,7 @@ async def _notify_gateway_removed(self, gateway: DbGateway) -> None: event = { "type": "gateway_removed", "data": {"id": gateway.id, "name": gateway.name, "is_active": False}, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), } await self._publish_event(event) diff --git a/mcpgateway/services/prompt_service.py b/mcpgateway/services/prompt_service.py index a4b9adca4..e5b7e4ea4 100644 --- a/mcpgateway/services/prompt_service.py +++ b/mcpgateway/services/prompt_service.py @@ -247,7 +247,7 @@ async def list_prompts(self, db: Session, include_inactive: bool = False, cursor prompts = db.execute(query).scalars().all() return [PromptRead.model_validate(self._convert_db_prompt(p)) for p in prompts] - async def list_server_prompts(self, db: Session, server_id: int, include_inactive: bool = False, cursor: Optional[str] = None) -> List[PromptRead]: + async def list_server_prompts(self, db: Session, server_id: str, include_inactive: bool = False, cursor: Optional[str] = None) -> List[PromptRead]: """ Retrieve a list of prompt templates from the database. @@ -258,7 +258,7 @@ async def list_server_prompts(self, db: Session, server_id: int, include_inactiv Args: db (Session): The SQLAlchemy database session. - server_id (int): Server ID + server_id (str): Server ID include_inactive (bool): If True, include inactive prompts in the result. Defaults to False. cursor (Optional[str], optional): An opaque cursor token for pagination. Currently, diff --git a/mcpgateway/services/resource_service.py b/mcpgateway/services/resource_service.py index e33e833c7..7d06c0ae7 100644 --- a/mcpgateway/services/resource_service.py +++ b/mcpgateway/services/resource_service.py @@ -233,7 +233,7 @@ async def list_resources(self, db: Session, include_inactive: bool = False) -> L resources = db.execute(query).scalars().all() return [self._convert_resource_to_read(r) for r in resources] - async def list_server_resources(self, db: Session, server_id: int, include_inactive: bool = False) -> List[ResourceRead]: + async def list_server_resources(self, db: Session, server_id: str, include_inactive: bool = False) -> List[ResourceRead]: """ Retrieve a list of registered resources from the database. @@ -244,7 +244,7 @@ async def list_server_resources(self, db: Session, server_id: int, include_inact Args: db (Session): The SQLAlchemy database session. - server_id (int): Server ID + server_id (str): Server ID include_inactive (bool): If True, include inactive resources in the result. Defaults to False. diff --git a/mcpgateway/services/server_service.py b/mcpgateway/services/server_service.py index 78a5e4f7e..c212b1e3e 100644 --- a/mcpgateway/services/server_service.py +++ b/mcpgateway/services/server_service.py @@ -107,7 +107,7 @@ def _convert_server_to_read(self, server: DbServer) -> ServerRead: "last_execution_time": last_time, } # Also update associated IDs (if not already done) - server_dict["associated_tools"] = [tool.id for tool in server.tools] if server.tools else [] + server_dict["associated_tools"] = [tool.name for tool in server.tools] if server.tools else [] server_dict["associated_resources"] = [res.id for res in server.resources] if server.resources else [] server_dict["associated_prompts"] = [prompt.id for prompt in server.prompts] if server.prompts else [] return ServerRead.model_validate(server_dict) @@ -182,7 +182,7 @@ async def register_server(self, db: Session, server_in: ServerCreate) -> ServerR for tool_id in server_in.associated_tools: if tool_id.strip() == "": continue - tool_obj = db.get(DbTool, int(tool_id)) + tool_obj = db.get(DbTool, tool_id) if not tool_obj: raise ServerError(f"Tool with id {tool_id} does not exist.") db_server.tools.append(tool_obj) @@ -253,7 +253,7 @@ async def list_servers(self, db: Session, include_inactive: bool = False) -> Lis servers = db.execute(query).scalars().all() return [self._convert_server_to_read(s) for s in servers] - async def get_server(self, db: Session, server_id: int) -> ServerRead: + async def get_server(self, db: Session, server_id: str) -> ServerRead: """Retrieve server details by ID. Args: @@ -277,14 +277,14 @@ async def get_server(self, db: Session, server_id: int) -> ServerRead: "created_at": server.created_at, "updated_at": server.updated_at, "is_active": server.is_active, - "associated_tools": [tool.id for tool in server.tools], + "associated_tools": [tool.name for tool in server.tools], "associated_resources": [res.id for res in server.resources], "associated_prompts": [prompt.id for prompt in server.prompts], } logger.debug(f"Server Data: {server_data}") return self._convert_server_to_read(server) - async def update_server(self, db: Session, server_id: int, server_update: ServerUpdate) -> ServerRead: + async def update_server(self, db: Session, server_id: str, server_update: ServerUpdate) -> ServerRead: """Update an existing server. Args: @@ -327,7 +327,7 @@ async def update_server(self, db: Session, server_id: int, server_update: Server if server_update.associated_tools is not None: server.tools = [] for tool_id in server_update.associated_tools: - tool_obj = db.get(DbTool, int(tool_id)) + tool_obj = db.get(DbTool, tool_id) if tool_obj: server.tools.append(tool_obj) @@ -375,7 +375,7 @@ async def update_server(self, db: Session, server_id: int, server_update: Server db.rollback() raise ServerError(f"Failed to update server: {str(e)}") - async def toggle_server_status(self, db: Session, server_id: int, activate: bool) -> ServerRead: + async def toggle_server_status(self, db: Session, server_id: str, activate: bool) -> ServerRead: """Toggle the activation status of a server. Args: @@ -424,7 +424,7 @@ async def toggle_server_status(self, db: Session, server_id: int, activate: bool db.rollback() raise ServerError(f"Failed to toggle server status: {str(e)}") - async def delete_server(self, db: Session, server_id: int) -> None: + async def delete_server(self, db: Session, server_id: str) -> None: """Permanently delete a server. Args: diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 565e3863c..9959f9026 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -18,6 +18,7 @@ import base64 import json import logging +import re import time from datetime import datetime from typing import Any, AsyncGenerator, Dict, List, Optional @@ -26,7 +27,7 @@ from mcp import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamablehttp_client -from sqlalchemy import delete, func, not_, select +from sqlalchemy import case, delete, func, literal, not_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -40,6 +41,7 @@ ToolUpdate, ) from mcpgateway.types import TextContent, ToolResult +from mcpgateway.utils.create_slug import slugify from mcpgateway.utils.services_auth import decode_auth from ..config import extract_using_jq @@ -147,6 +149,11 @@ def _convert_tool_to_read(self, tool: DbTool) -> ToolRead: } else: tool_dict["auth"] = None + + tool_dict["name"] = tool.name + tool_dict["gateway_slug"] = tool.gateway_slug if tool.gateway_slug else "" + tool_dict["original_name_slug"] = tool.original_name_slug + return ToolRead.model_validate(tool_dict) async def _record_tool_metric(self, db: Session, tool: DbTool, start_time: float, success: bool, error_message: Optional[str]) -> None: @@ -190,10 +197,13 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead: ToolError: For other tool registration errors. """ try: - existing_tool = db.execute(select(DbTool).where(DbTool.name == tool.name)).scalar_one_or_none() + if not tool.gateway_id: + existing_tool = db.execute(select(DbTool).where(DbTool.name == tool.name)).scalar_one_or_none() + else: + existing_tool = db.execute(select(DbTool).where(DbTool.name == tool.name).where(DbTool.gateway_id == tool.gateway_id)).scalar_one_or_none() if existing_tool: raise ToolNameConflictError( - tool.name, + existing_tool.name, is_active=existing_tool.is_active, tool_id=existing_tool.id, ) @@ -206,7 +216,8 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead: auth_value = tool.auth.auth_value db_tool = DbTool( - name=tool.name, + original_name=tool.name, + original_name_slug=slugify(tool.name), url=str(tool.url), description=tool.description, integration_type=tool.integration_type, @@ -222,7 +233,7 @@ async def register_tool(self, db: Session, tool: ToolCreate) -> ToolRead: db.commit() db.refresh(db_tool) await self._notify_tool_added(db_tool) - logger.info(f"Registered tool: {tool.name}") + logger.info(f"Registered tool: {db_tool.name}") return self._convert_tool_to_read(db_tool) except IntegrityError: db.rollback() @@ -253,13 +264,13 @@ async def list_tools(self, db: Session, include_inactive: bool = False, cursor: tools = db.execute(query).scalars().all() return [self._convert_tool_to_read(t) for t in tools] - async def list_server_tools(self, db: Session, server_id: int, include_inactive: bool = False, cursor: Optional[str] = None) -> List[ToolRead]: + async def list_server_tools(self, db: Session, server_id: str, include_inactive: bool = False, cursor: Optional[str] = None) -> List[ToolRead]: """ Retrieve a list of registered tools from the database. Args: db (Session): The SQLAlchemy database session. - server_id (int): Server ID + server_id (str): Server ID include_inactive (bool): If True, include inactive tools in the result. Defaults to False. cursor (Optional[str], optional): An opaque cursor token for pagination. Currently, @@ -276,7 +287,7 @@ async def list_server_tools(self, db: Session, server_id: int, include_inactive: tools = db.execute(query).scalars().all() return [self._convert_tool_to_read(t) for t in tools] - async def get_tool(self, db: Session, tool_id: int) -> ToolRead: + async def get_tool(self, db: Session, tool_id: str) -> ToolRead: """Get a specific tool by ID. Args: @@ -294,7 +305,7 @@ async def get_tool(self, db: Session, tool_id: int) -> ToolRead: raise ToolNotFoundError(f"Tool not found: {tool_id}") return self._convert_tool_to_read(tool) - async def delete_tool(self, db: Session, tool_id: int) -> None: + async def delete_tool(self, db: Session, tool_id: str) -> None: """Permanently delete a tool from the database. Args: @@ -318,7 +329,7 @@ async def delete_tool(self, db: Session, tool_id: int) -> None: db.rollback() raise ToolError(f"Failed to delete tool: {str(e)}") - async def toggle_tool_status(self, db: Session, tool_id: int, activate: bool) -> ToolRead: + async def toggle_tool_status(self, db: Session, tool_id: str, activate: bool) -> ToolRead: """Toggle tool active status. Args: @@ -352,114 +363,6 @@ async def toggle_tool_status(self, db: Session, tool_id: int, activate: bool) -> db.rollback() raise ToolError(f"Failed to toggle tool status: {str(e)}") - # async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any]) -> ToolResult: - # """ - # Invoke a registered tool and record execution metrics. - - # Args: - # db: Database session. - # name: Name of tool to invoke. - # arguments: Tool arguments. - - # Returns: - # Tool invocation result. - - # Raises: - # ToolNotFoundError: If tool not found. - # ToolInvocationError: If invocation fails. - # """ - - # tool = db.execute(select(DbTool).where(DbTool.name == name).where(DbTool.is_active)).scalar_one_or_none() - # if not tool: - # inactive_tool = db.execute(select(DbTool).where(DbTool.name == name).where(not_(DbTool.is_active))).scalar_one_or_none() - # if inactive_tool: - # raise ToolNotFoundError(f"Tool '{name}' exists but is inactive") - # raise ToolNotFoundError(f"Tool not found: {name}") - # start_time = time.monotonic() - # success = False - # error_message = None - # try: - # # tool.validate_arguments(arguments) - # # Build headers with auth if necessary. - # headers = tool.headers or {} - # if tool.integration_type == "REST": - # credentials = decode_auth(tool.auth_value) - # headers.update(credentials) - - # # Build the payload based on integration type. - # payload = arguments - - # # Use the tool's request_type rather than defaulting to POST. - # method = tool.request_type.upper() - # if method == "GET": - # response = await self._http_client.get(tool.url, params=payload, headers=headers) - # else: - # response = await self._http_client.request(method, tool.url, json=payload, headers=headers) - # response.raise_for_status() - # result = response.json() - - # if response.status_code not in [200, 201, 202, 204, 206]: - # tool_result = ToolResult( - # content=[TextContent(type="text", text=str(result["error"]) if "error" in result else "Tool error encountered")], - # is_error=True, - # ) - # else: - # filtered_response = extract_using_jq(result, tool.jsonpath_filter) - # tool_result = ToolResult(content=[TextContent(type="text", text=json.dumps(filtered_response, indent=2))]) - - # success = True - # elif tool.integration_type == "MCP": - # gateway = db.execute(select(DbGateway).where(DbGateway.id == tool.gateway_id).where(DbGateway.is_active)).scalar_one_or_none() - # if gateway.auth_type == "bearer": - # headers = decode_auth(gateway.auth_value) - # else: - # headers = {} - - # async def connect_to_sse_server(server_url: str): - # """ - # Connect to an MCP server running with SSE transport - - # Args: - # server_url: Server URL - - # Returns: - # str: Tool call result - # """ - # # Store the context managers so they stay alive - # _streams_context = sse_client(url=server_url, headers=headers) - # streams = await _streams_context.__aenter__() #line 422 - - # _session_context = ClientSession(*streams) - # session: ClientSession = await _session_context.__aenter__() #line 425 - - # # Initialize - # await session.initialize() - # tool_call_result = await session.call_tool(name, arguments) - - # await _session_context.__aexit__(None, None, None) - # await _streams_context.__aexit__(None, None, None) #line 432 - - # return tool_call_result - - # tool_gateway_id = tool.gateway_id - # tool_gateway = db.execute(select(DbGateway).where(DbGateway.id == tool_gateway_id).where(DbGateway.is_active)).scalar_one_or_none() - - # tool_call_result = await connect_to_sse_server(tool_gateway.url) - # content = tool_call_result.model_dump(by_alias=True).get("content", []) - - # success = True - # filtered_response = extract_using_jq(content, tool.jsonpath_filter) - # tool_result = ToolResult(content=filtered_response) - # else: - # return ToolResult(content="Invalid tool type") - - # return tool_result - # except Exception as e: - # error_message = str(e) - # raise ToolInvocationError(f"Tool invocation failed: {error_message}") - # finally: - # await self._record_tool_metric(db, tool, start_time, success, error_message) - async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any]) -> ToolResult: """ Invoke a registered tool and record execution metrics. @@ -476,9 +379,17 @@ async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any]) - ToolNotFoundError: If tool not found. ToolInvocationError: If invocation fails. """ - tool = db.execute(select(DbTool).where(DbTool.name == name).where(DbTool.is_active)).scalar_one_or_none() + separator = literal(settings.gateway_tool_name_separator) + slug_expr = case( + ( + DbTool.gateway_slug.is_(None), # pylint: disable=no-member + DbTool.original_name_slug, + ), # WHEN gateway_slug IS NULL + else_=DbTool.gateway_slug + separator + DbTool.original_name_slug, # ELSE gateway_slug||sep||original + ) + tool = db.execute(select(DbTool).where(slug_expr == name).where(DbTool.is_active)).scalar_one_or_none() if not tool: - inactive_tool = db.execute(select(DbTool).where(DbTool.name == name).where(not_(DbTool.is_active))).scalar_one_or_none() + inactive_tool = db.execute(select(DbTool).where(slug_expr == name).where(not_(DbTool.is_active))).scalar_one_or_none() if inactive_tool: raise ToolNotFoundError(f"Tool '{name}' exists but is inactive") raise ToolNotFoundError(f"Tool not found: {name}") @@ -500,8 +411,6 @@ async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any]) - final_url = tool.url if "{" in tool.url and "}" in tool.url: # Extract path parameters from URL template and arguments - import re - url_params = re.findall(r"\{(\w+)\}", tool.url) url_substitutions = {} @@ -558,7 +467,7 @@ async def connect_to_sse_server(server_url: str) -> str: async with ClientSession(*streams) as session: # Initialize the session await session.initialize() - tool_call_result = await session.call_tool(name, arguments) + tool_call_result = await session.call_tool(tool.original_name, arguments) return tool_call_result async def connect_to_streamablehttp_server(server_url: str) -> str: @@ -572,16 +481,17 @@ async def connect_to_streamablehttp_server(server_url: str) -> str: str: Result of tool call """ # Use async with directly to manage the context - async with streamablehttp_client(url=server_url, headers=headers) as (read_stream, write_stream, get_session_id): + async with streamablehttp_client(url=server_url, headers=headers) as (read_stream, write_stream, _get_session_id): async with ClientSession(read_stream, write_stream) as session: # Initialize the session await session.initialize() - tool_call_result = await session.call_tool(name, arguments) + tool_call_result = await session.call_tool(tool.original_name, arguments) return tool_call_result tool_gateway_id = tool.gateway_id tool_gateway = db.execute(select(DbGateway).where(DbGateway.id == tool_gateway_id).where(DbGateway.is_active)).scalar_one_or_none() + tool_call_result = ToolResult(content=[TextContent(text="", type="text")]) if transport == "sse": tool_call_result = await connect_to_sse_server(tool_gateway.url) elif transport == "streamablehttp": @@ -601,7 +511,7 @@ async def connect_to_streamablehttp_server(server_url: str) -> str: finally: await self._record_tool_metric(db, tool, start_time, success, error_message) - async def update_tool(self, db: Session, tool_id: int, tool_update: ToolUpdate) -> ToolRead: + async def update_tool(self, db: Session, tool_id: str, tool_update: ToolUpdate) -> ToolRead: """Update an existing tool. Args: @@ -621,8 +531,8 @@ async def update_tool(self, db: Session, tool_id: int, tool_update: ToolUpdate) tool = db.get(DbTool, tool_id) if not tool: raise ToolNotFoundError(f"Tool not found: {tool_id}") - if tool_update.name is not None and tool_update.name != tool.name: - existing_tool = db.execute(select(DbTool).where(DbTool.name == tool_update.name).where(DbTool.id != tool_id)).scalar_one_or_none() + if tool_update.name is not None and not (tool_update.name == tool.name and tool_update.gateway_id == tool.gateway_id): + existing_tool = db.execute(select(DbTool).where(DbTool.name == tool_update.name).where(DbTool.gateway_id == tool_update.gateway_id).where(DbTool.id != tool_id)).scalar_one_or_none() if existing_tool: raise ToolNameConflictError( tool_update.name, diff --git a/mcpgateway/static/admin.css b/mcpgateway/static/admin.css index 4f1fd66f8..873e3974b 100644 --- a/mcpgateway/static/admin.css +++ b/mcpgateway/static/admin.css @@ -28,3 +28,5 @@ 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + +.feedback:blank { display:none; } diff --git a/mcpgateway/static/admin.js b/mcpgateway/static/admin.js index bc7c23b5f..85b188045 100644 --- a/mcpgateway/static/admin.js +++ b/mcpgateway/static/admin.js @@ -578,11 +578,11 @@ async function viewTool(toolId) { ${authHTML}
Headers: -
${JSON.stringify(tool.headers || {}, null, 2)}
+
${JSON.stringify(tool.headers || {}, null, 2)}
Input Schema: -
${JSON.stringify(tool.inputSchema || {}, null, 2)}
+
${JSON.stringify(tool.inputSchema || {}, null, 2)}
Metrics: @@ -607,6 +607,54 @@ async function viewTool(toolId) { } } +function protectInputPrefix(inputElement, protectedText) { + let lastValidValue = protectedText; + + // Set initial value + inputElement.value = protectedText; + + // Listen for input events + inputElement.addEventListener('input', function(e) { + const currentValue = e.target.value; + + // Check if protected text is still intact + if (!currentValue.startsWith(protectedText)) { + // Restore the protected text + e.target.value = lastValidValue; + // Move cursor to end of protected text + e.target.setSelectionRange(protectedText.length, protectedText.length); + } else { + // Save valid state + lastValidValue = currentValue; + } + }); + + // Prevent selection/editing of protected portion + inputElement.addEventListener('keydown', function(e) { + const start = e.target.selectionStart; + const end = e.target.selectionEnd; + + // Block edits that would affect protected text + if (start < protectedText.length) { + // Allow navigation keys + const allowedKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Tab']; + if (!allowedKeys.includes(e.key)) { + e.preventDefault(); + // Move cursor to end of protected text + e.target.setSelectionRange(protectedText.length, protectedText.length); + } + } + }); + + // Handle paste events + inputElement.addEventListener('paste', function(e) { + const start = e.target.selectionStart; + if (start < protectedText.length) { + e.preventDefault(); + } + }); +} + /** * Fetches tool details from the backend and populates the edit modal form, * including Request Type and Authentication fields, so that they are pre-filled for editing. @@ -621,6 +669,11 @@ async function editTool(toolId) { // Set form action and populate basic fields. document.getElementById("edit-tool-form").action = `${window.ROOT_PATH}/admin/tools/${toolId}/edit`; + // const toolNameInput = document.getElementById("edit-tool-name"); + // const protectedPrefix = tool.gatewaySlug + `${window.GATEWAY_TOOL_NAME_SEPARATOR}`; + // protectInputPrefix(toolNameInput, protectedPrefix); + // toolNameInput.value = protectedPrefix + (tool.name.startsWith(protectedPrefix) ? + // tool.name.substring(protectedPrefix.length) : tool.name); document.getElementById("edit-tool-name").value = tool.name; document.getElementById("edit-tool-url").value = tool.url; document.getElementById("edit-tool-description").value = @@ -968,7 +1021,7 @@ async function viewServer(serverId) { // Otherwise, lookup the name using the mapping (fallback to the id itself) const name = mapping[item] || item; return ` - ${item}:${name} + ${name} `; } }; @@ -1063,11 +1116,38 @@ async function editServer(serverId) { server.description || ""; document.getElementById("edit-server-icon").value = server.icon || ""; // Fill in the associated tools field (already working) - document.getElementById("edit-server-tools").value = Array.isArray( - server.associatedTools, - ) - ? server.associatedTools.join(", ") - : ""; + const select = document.getElementById('edit-server-tools'); + const pillsBox = document.getElementById('selectedEditToolsPills'); + const warnBox = document.getElementById('selectedEditToolsWarning'); + + // mark every matching
- - + + + +