Skip to content

Commit 6216fe9

Browse files
author
Olivier Gintrand
committed
feat(api): comprehensive Virtual Meta-Server with 12 meta-tools
Implements the Virtual Meta-Server feature (#2230) — a tool aggregation layer that enables AI agents to discover and invoke thousands of underlying tools through a unified interface. Meta-tools: - search_tools: hybrid semantic + keyword search with scope filtering - list_tools: paginated tool listing with sorting and filtering - describe_tool: detailed tool info with schema and metadata - execute_tool: tool execution with JSON schema validation and routing - get_tool_categories: aggregated categories with counts - get_similar_tools: vector similarity search for related tools - authorize_gateway: interactive OAuth authorization with token refresh - authorize_all_gateways: one-click authorization for all OAuth gateways - list_resources: paginated MCP resource listing - read_resource: read MCP resource content by URI - list_prompts: paginated MCP prompt listing - get_prompt: prompt template retrieval with optional rendering Features: - OAuth integration: propagates user identity through the call chain - Chained OAuth flow: authorize-all endpoint chains multiple gateways - camelCase normalization for MCP clients - Flat argument tolerance for Copilot Studio - Post-login redirect via cookie with safe path validation - Observability: prompt.render and resource.read spans - JSON serialization: orjson.dumps() for proper JSON output - Admin UI: meta-server checkbox and hide-underlying-tools in server forms - Preserves protect_oauth_config_for_storage() on server update - RBAC enforcement via middleware on all meta endpoints Closes #2230 Supersedes #3653 Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
1 parent a02a04b commit 6216fe9

33 files changed

+6677
-266
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,11 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
21362136
# TOOL_CONCURRENT_LIMIT=10
21372137
# GATEWAY_TOOL_NAME_SEPARATOR=-
21382138

2139+
# Maximum length of response text returned for non-JSON REST API responses
2140+
# Longer responses are truncated to prevent exposing excessive sensitive data
2141+
# Default: 5000 characters, Range: 1000-100000
2142+
# REST_RESPONSE_TEXT_MAX_LENGTH=5000
2143+
21392144
# Prompt Configuration
21402145
# PROMPT_CACHE_SIZE=100
21412146
# MAX_PROMPT_SIZE=102400

.secrets.baseline

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline|package-lock.json|Cargo.lock|scripts/sign_image.sh|scripts/zap|sonar-project.properties|uv.lock|go.sum|mcpgateway/sri_hashes.json|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-14T13:09:46Z",
6+
"generated_at": "2026-04-14T14:08:10Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -4830,7 +4830,7 @@
48304830
"hashed_secret": "ff37a98a9963d347e9749a5c1b3936a4a245a6ff",
48314831
"is_secret": false,
48324832
"is_verified": false,
4833-
"line_number": 2228,
4833+
"line_number": 2236,
48344834
"type": "Secret Keyword",
48354835
"verified_result": null
48364836
}
@@ -8624,39 +8624,39 @@
86248624
"hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc",
86258625
"is_secret": false,
86268626
"is_verified": false,
8627-
"line_number": 6376,
8627+
"line_number": 6907,
86288628
"type": "Secret Keyword",
86298629
"verified_result": null
86308630
},
86318631
{
86328632
"hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750",
86338633
"is_secret": false,
86348634
"is_verified": false,
8635-
"line_number": 6868,
8635+
"line_number": 7399,
86368636
"type": "Secret Keyword",
86378637
"verified_result": null
86388638
},
86398639
{
86408640
"hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295",
86418641
"is_secret": false,
86428642
"is_verified": false,
8643-
"line_number": 8215,
8643+
"line_number": 8746,
86448644
"type": "Secret Keyword",
86458645
"verified_result": null
86468646
},
86478647
{
86488648
"hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2",
86498649
"is_secret": false,
86508650
"is_verified": false,
8651-
"line_number": 8357,
8651+
"line_number": 8888,
86528652
"type": "Secret Keyword",
86538653
"verified_result": null
86548654
},
86558655
{
86568656
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
86578657
"is_secret": false,
86588658
"is_verified": false,
8659-
"line_number": 8733,
8659+
"line_number": 9264,
86608660
"type": "Secret Keyword",
86618661
"verified_result": null
86628662
}

mcpgateway/admin.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,50 @@ def validate_password_strength(password: str) -> tuple[bool, str]:
14061406
ADMIN_CSRF_FORM_FIELD = "csrf_token"
14071407

14081408

1409+
def _resolve_root_path(request: Request) -> str:
1410+
"""Resolve the application root path from the request scope with fallback.
1411+
1412+
Some embedded/proxy deployments do not populate ``scope["root_path"]``
1413+
consistently. This helper checks the ASGI scope first and falls back
1414+
to ``settings.app_root_path`` when the scope value is empty.
1415+
1416+
Args:
1417+
request: Incoming request used to read ASGI ``root_path``.
1418+
1419+
Returns:
1420+
Normalized root path (leading ``/``, no trailing ``/``), or empty
1421+
string when no root path is configured.
1422+
"""
1423+
root_path = request.scope.get("root_path", "") or ""
1424+
if not root_path or not str(root_path).strip():
1425+
root_path = settings.app_root_path or ""
1426+
root_path = str(root_path).strip()
1427+
if root_path:
1428+
root_path = "/" + root_path.lstrip("/")
1429+
return root_path.rstrip("/")
1430+
1431+
1432+
def _is_safe_local_path(path: str) -> bool:
1433+
"""Validate that a path is a safe local redirect target (no open redirect).
1434+
1435+
Args:
1436+
path: The path to validate.
1437+
1438+
Returns:
1439+
True if the path is a safe relative path starting with ``/``.
1440+
"""
1441+
if not path or not isinstance(path, str):
1442+
return False
1443+
if not path.startswith("/"):
1444+
return False
1445+
# Block protocol-relative URLs (//evil.com), authority injection (@), backslash tricks
1446+
if path.startswith("//") or "@" in path or "\\" in path:
1447+
return False
1448+
parsed = urllib.parse.urlparse(path)
1449+
if parsed.scheme or parsed.netloc:
1450+
return False
1451+
return True
1452+
14091453
def _admin_cookie_path(request: Request) -> str:
14101454
"""Build admin cookie path honoring ASGI root_path.
14111455

@@ -2978,6 +3022,8 @@ async def admin_add_server(request: Request, db: Session = Depends(get_db), user
29783022
visibility=visibility,
29793023
oauth_enabled=oauth_enabled,
29803024
oauth_config=oauth_config,
3025+
server_type="meta" if form.get("meta_server_enabled") else str(form.get("server_type", "standard")),
3026+
hide_underlying_tools=form.get("hide_underlying_tools") == "true" or form.get("hide_underlying_tools") == "on",
29813027
)
29823028
except KeyError as e:
29833029
# Convert KeyError to ValidationError-like response
@@ -3141,6 +3187,8 @@ async def admin_edit_server(
31413187
owner_email=user_email,
31423188
oauth_enabled=oauth_enabled,
31433189
oauth_config=oauth_config,
3190+
server_type="meta" if form.get("meta_server_enabled") else str(form.get("server_type", "standard")),
3191+
hide_underlying_tools=form.get("hide_underlying_tools") == "true" or form.get("hide_underlying_tools") == "on",
31443192
)
31453193

31463194
await server_service.update_server(
@@ -4211,6 +4259,21 @@ async def admin_login_page(request: Request) -> Response:
42114259
response.delete_cookie("jwt_token", path="/")
42124260
response.delete_cookie("access_token", path="/")
42134261

4262+
# Preserve ?next= parameter as a short-lived cookie so SSO callback can redirect
4263+
# back to the original URL (e.g. /oauth/authorize/{gateway_id}) after login.
4264+
next_url = request.query_params.get("next", "")
4265+
if next_url and _is_safe_local_path(next_url):
4266+
use_secure = (settings.environment == "production") or settings.secure_cookies
4267+
response.set_cookie(
4268+
key="post_login_next",
4269+
value=next_url,
4270+
max_age=300, # 5 minutes — enough for SSO round-trip
4271+
httponly=True,
4272+
secure=use_secure,
4273+
samesite=settings.cookie_samesite,
4274+
path=settings.app_root_path or "/",
4275+
)
4276+
42144277
return response
42154278

42164279

mcpgateway/admin_ui/formSubmitHandlers.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,17 @@ export const handleServerFormSubmit = async function (e) {
438438
}
439439
}
440440

441+
// Handle Meta-Server configuration
442+
const metaEnabledCheckbox = safeGetElement("server-meta-enabled");
443+
if (metaEnabledCheckbox && metaEnabledCheckbox.checked) {
444+
formData.set("server_type", "meta");
445+
const hideToolsCheckbox = safeGetElement("server-hide-underlying-tools");
446+
formData.set("hide_underlying_tools", hideToolsCheckbox && hideToolsCheckbox.checked ? "true" : "false");
447+
} else {
448+
formData.set("server_type", "standard");
449+
formData.delete("hide_underlying_tools");
450+
}
451+
441452
const response = await fetch(`${window.ROOT_PATH}/admin/servers`, {
442453
method: "POST",
443454
body: formData,
@@ -1008,6 +1019,17 @@ export const handleEditServerFormSubmit = async function (e) {
10081019
}
10091020
});
10101021

1022+
// Handle Meta-Server configuration
1023+
const metaEnabledCheckbox = safeGetElement("edit-server-meta-enabled");
1024+
if (metaEnabledCheckbox && metaEnabledCheckbox.checked) {
1025+
formData.set("server_type", "meta");
1026+
const hideToolsCheckbox = safeGetElement("edit-server-hide-underlying-tools");
1027+
formData.set("hide_underlying_tools", hideToolsCheckbox && hideToolsCheckbox.checked ? "true" : "false");
1028+
} else {
1029+
formData.set("server_type", "standard");
1030+
formData.delete("hide_underlying_tools");
1031+
}
1032+
10111033
// Submit via fetch
10121034
const response = await fetch(form.action, {
10131035
method: "POST",

mcpgateway/admin_ui/servers.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,29 @@ export const editServer = async function (serverId) {
864864
if (oauthTokenEndpointField) oauthTokenEndpointField.value = "";
865865
}
866866

867+
// Set Meta-Server configuration fields
868+
const metaEnabledCheckbox = safeGetElement("edit-server-meta-enabled");
869+
const metaConfigSection = safeGetElement("edit-server-meta-config-section");
870+
const hideUnderlyingToolsCheckbox = safeGetElement("edit-server-hide-underlying-tools");
871+
const isMeta = server.serverType === "meta" || server.server_type === "meta";
872+
873+
if (metaEnabledCheckbox) {
874+
metaEnabledCheckbox.checked = isMeta;
875+
}
876+
if (metaConfigSection) {
877+
if (isMeta) {
878+
metaConfigSection.classList.remove("hidden");
879+
} else {
880+
metaConfigSection.classList.add("hidden");
881+
}
882+
}
883+
if (hideUnderlyingToolsCheckbox) {
884+
const hideTools = server.hideUnderlyingTools !== undefined
885+
? server.hideUnderlyingTools
886+
: (server.hide_underlying_tools !== undefined ? server.hide_underlying_tools : true);
887+
hideUnderlyingToolsCheckbox.checked = isMeta ? hideTools : true;
888+
}
889+
867890
// Store server data for modal population
868891
window.Admin.currentEditingServer = server;
869892

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# -*- coding: utf-8 -*-
2+
"""Add meta-server fields to servers table
3+
4+
Revision ID: 5126ced48fd0
5+
Revises: 64acf94cb7f2
6+
Create Date: 2026-02-12 10:00:00.000000
7+
8+
"""
9+
10+
# Third-Party
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "5126ced48fd0"
16+
down_revision = "64acf94cb7f2"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
"""Add server_type, hide_underlying_tools, meta_config, and meta_scope columns to servers."""
23+
inspector = sa.inspect(op.get_bind())
24+
25+
# Skip if table doesn't exist (fresh DB uses db.py models directly)
26+
if "servers" not in inspector.get_table_names():
27+
return
28+
29+
columns = [col["name"] for col in inspector.get_columns("servers")]
30+
31+
if "server_type" not in columns:
32+
op.add_column("servers", sa.Column("server_type", sa.String(20), nullable=False, server_default="standard"))
33+
34+
if "hide_underlying_tools" not in columns:
35+
op.add_column("servers", sa.Column("hide_underlying_tools", sa.Boolean(), nullable=False, server_default=sa.text("true")))
36+
37+
if "meta_config" not in columns:
38+
op.add_column("servers", sa.Column("meta_config", sa.JSON(), nullable=True))
39+
40+
if "meta_scope" not in columns:
41+
op.add_column("servers", sa.Column("meta_scope", sa.JSON(), nullable=True))
42+
43+
44+
def downgrade() -> None:
45+
"""Remove meta-server fields from servers table."""
46+
inspector = sa.inspect(op.get_bind())
47+
48+
if "servers" not in inspector.get_table_names():
49+
return
50+
51+
columns = [col["name"] for col in inspector.get_columns("servers")]
52+
53+
if "meta_scope" in columns:
54+
op.drop_column("servers", "meta_scope")
55+
if "meta_config" in columns:
56+
op.drop_column("servers", "meta_config")
57+
if "hide_underlying_tools" in columns:
58+
op.drop_column("servers", "hide_underlying_tools")
59+
if "server_type" in columns:
60+
op.drop_column("servers", "server_type")

mcpgateway/common/validators.py

Lines changed: 4 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,12 @@
5050
# Standard
5151
from html.parser import HTMLParser
5252
import ipaddress
53-
import json
5453
import logging
5554
from pathlib import Path
5655
import re
5756
import shlex
5857
import socket
59-
from typing import Any, Dict, Iterable, List, Optional, Pattern
58+
from typing import Any, Iterable, List, Optional, Pattern
6059
from urllib.parse import urlparse
6160
import uuid
6261

@@ -77,7 +76,9 @@
7776
_HTML_SPECIAL_CHARS_RE: Pattern[str] = re.compile(r'[<>"\']') # / removed per SEP-986
7877
_DANGEROUS_TEMPLATE_TAGS_RE: Pattern[str] = re.compile(r"<(script|iframe|object|embed|link|meta|base|form)\b", re.IGNORECASE)
7978
_EVENT_HANDLER_RE: Pattern[str] = re.compile(r"on\w+\s*=", re.IGNORECASE)
80-
_MIME_TYPE_RE: Pattern[str] = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*(?:\s*;\s*[a-zA-Z0-9!#$&\-\^_+\.]+=(?:[a-zA-Z0-9!#$&\-\^_+\.]+|"[^"\r\n]*"))*$')
79+
_MIME_TYPE_RE: Pattern[str] = re.compile(
80+
r'^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+\.]*(?:\s*;\s*[a-zA-Z0-9!#$&\-\^_+\.]+=(?:[a-zA-Z0-9!#$&\-\^_+\.]+|"[^"\r\n]*"))*$'
81+
)
8182
_URI_SCHEME_RE: Pattern[str] = re.compile(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://")
8283
_SHELL_DANGEROUS_CHARS_RE: Pattern[str] = re.compile(r"[;&|`$(){}\[\]<>]")
8384
_ANSI_ESCAPE_RE: Pattern[str] = re.compile(r"\x1B\[[0-9;]*[A-Za-z]")
@@ -1814,60 +1815,3 @@ def validate_core_url(value: str, field_name: str = "URL") -> str:
18141815
The validated URL string.
18151816
"""
18161817
return SecurityValidator.validate_url(value, field_name)
1817-
1818-
1819-
# CWE-400: Limits for user-supplied meta_data forwarded to upstream MCP servers.
1820-
# Keeps arbitrarily large dicts from amplifying into downstream network/DB load.
1821-
# These are now read from config (settings.meta_max_keys, etc.) but kept as
1822-
# module-level aliases for backward-compatible imports.
1823-
META_MAX_KEYS: int = settings.meta_max_keys
1824-
META_MAX_DEPTH: int = settings.meta_max_depth
1825-
META_MAX_BYTES: int = settings.meta_max_bytes
1826-
1827-
1828-
def validate_meta_data(meta_data: Optional[Dict[str, Any]]) -> None:
1829-
"""Enforce size, key-count, and depth limits on user-supplied meta_data (CWE-400).
1830-
1831-
Args:
1832-
meta_data: The metadata dictionary to validate. ``None`` is always accepted.
1833-
1834-
Raises:
1835-
ValueError: if any limit is exceeded.
1836-
"""
1837-
max_keys = settings.meta_max_keys
1838-
max_depth = settings.meta_max_depth
1839-
max_bytes = settings.meta_max_bytes
1840-
1841-
if not meta_data:
1842-
return
1843-
if len(meta_data) > max_keys:
1844-
raise ValueError(f"meta_data exceeds maximum key count ({max_keys}): got {len(meta_data)}")
1845-
1846-
def _check_depth(obj: Any, depth: int) -> None:
1847-
"""Recursively enforce nesting depth, traversing both dicts and lists (CWE-400).
1848-
1849-
Lists are traversed without incrementing the depth counter so that a
1850-
list-of-dicts does not hide an extra level of dict nesting — e.g.
1851-
``{"k": [{"l2": {"l3": "x"}}]}`` is correctly caught as depth 3.
1852-
"""
1853-
if depth > max_depth:
1854-
raise ValueError(f"meta_data exceeds maximum nesting depth ({max_depth})")
1855-
if isinstance(obj, dict):
1856-
for v in obj.values():
1857-
_check_depth(v, depth + 1)
1858-
elif isinstance(obj, list):
1859-
for item in obj:
1860-
_check_depth(item, depth)
1861-
1862-
for v in meta_data.values():
1863-
_check_depth(v, 1)
1864-
1865-
try:
1866-
# CWE-20: Use strict json.dumps (no default=str) so non-serializable objects
1867-
# raise TypeError rather than being silently coerced — keeps the byte limit
1868-
# meaningful and matches the strict rejection behaviour used in prompt_service.
1869-
size = len(json.dumps(meta_data))
1870-
if size > max_bytes:
1871-
raise ValueError(f"meta_data exceeds maximum size ({max_bytes} bytes): got {size}")
1872-
except (TypeError, ValueError) as exc:
1873-
raise ValueError(f"meta_data is not serializable: {exc}") from exc

mcpgateway/config.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,6 @@ class Settings(BaseSettings):
360360
allowed_roots: List[str] = Field(default_factory=list, description="Allowed root paths for resource access")
361361
max_path_depth: int = Field(default=10, description="Maximum allowed path depth")
362362
max_param_length: int = Field(default=10000, description="Maximum parameter length")
363-
meta_max_keys: int = Field(default=16, description="Maximum number of keys in user-supplied meta_data forwarded to upstream MCP servers (CWE-400)")
364-
meta_max_depth: int = Field(default=2, description="Maximum nesting depth for user-supplied meta_data forwarded to upstream MCP servers (CWE-400)")
365-
meta_max_bytes: int = Field(default=4096, description="Maximum JSON-encoded byte size for user-supplied meta_data forwarded to upstream MCP servers (CWE-400)")
366363
dangerous_patterns: List[str] = Field(
367364
default_factory=lambda: [
368365
r"[;&|`$(){}\[\]<>]", # Shell metacharacters
@@ -1603,6 +1600,7 @@ def parse_issuers(cls, v: Any) -> list[str]:
16031600
max_tool_retries: int = 3
16041601
tool_rate_limit: int = 100 # requests per minute
16051602
tool_concurrent_limit: int = 10
1603+
semantic_search_rate_limit: int = 30 # requests per minute for semantic search
16061604

16071605
# Content Security - Size Limits
16081606
content_max_resource_size: int = Field(default=102400, ge=1024, le=10485760, description="Maximum size in bytes for resource content (default: 100KB)") # 100KB # Minimum 1KB # Maximum 10MB

0 commit comments

Comments
 (0)