fred-runtime 3.3.1__tar.gz → 3.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/PKG-INFO +1 -1
  2. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/agent_app.py +50 -4
  3. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/mcp_runtime.py +111 -17
  4. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/mcp_utils.py +17 -8
  5. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/graph/graph_runtime.py +0 -1
  6. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/integrations/v2_runtime/adapters.py +1 -2
  7. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_runtime.py +0 -1
  8. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime.egg-info/PKG-INFO +1 -1
  9. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/pyproject.toml +1 -1
  10. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_agent_app.py +132 -3
  11. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_react_thinking.py +1 -1
  12. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/README.md +0 -0
  13. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/__init__.py +0 -0
  14. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/__init__.py +0 -0
  15. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/_catalogs.py +0 -0
  16. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/config.py +0 -0
  17. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/config_loader.py +0 -0
  18. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/container.py +0 -0
  19. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/context.py +0 -0
  20. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/dependencies.py +0 -0
  21. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/mcp_config.py +0 -0
  22. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/observability_factory.py +0 -0
  23. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/app/openai_compat_router.py +0 -0
  24. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/__init__.py +0 -0
  25. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/completion.py +0 -0
  26. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/entrypoint.py +0 -0
  27. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/history_display.py +0 -0
  28. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/kpi_display.py +0 -0
  29. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/pod_client.py +0 -0
  30. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/repl.py +0 -0
  31. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/repl_helpers.py +0 -0
  32. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/cli/url_helpers.py +0 -0
  33. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/client.py +0 -0
  34. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/__init__.py +0 -0
  35. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/context_aware_tool.py +0 -0
  36. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_base_client.py +0 -0
  37. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_fast_text_client.py +0 -0
  38. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_http_client.py +0 -0
  39. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_logs_client.py +0 -0
  40. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_markdown_media_client.py +0 -0
  41. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_vectorsearch_client.py +0 -0
  42. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/kf_workspace_client.py +0 -0
  43. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/mcp_interceptors.py +0 -0
  44. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/mcp_toolkit.py +0 -0
  45. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/structures.py +0 -0
  46. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/token_expiry.py +0 -0
  47. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/common/tool_node_utils.py +0 -0
  48. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/deep/__init__.py +0 -0
  49. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/deep/deep_runtime.py +0 -0
  50. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/eval/__init__.py +0 -0
  51. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/eval/collector.py +0 -0
  52. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/graph/__init__.py +0 -0
  53. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/integrations/__init__.py +0 -0
  54. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/integrations/inprocess_toolkit_registry.py +0 -0
  55. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/integrations/kf_vector_search/__init__.py +0 -0
  56. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/integrations/kf_vector_search/toolkit.py +0 -0
  57. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/integrations/v2_runtime/__init__.py +0 -0
  58. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/model_routing/__init__.py +0 -0
  59. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/model_routing/catalog.py +0 -0
  60. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/model_routing/contracts.py +0 -0
  61. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/model_routing/provider.py +0 -0
  62. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/model_routing/resolver.py +0 -0
  63. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/__init__.py +0 -0
  64. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_langchain_adapter.py +0 -0
  65. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_message_codec.py +0 -0
  66. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_model_adapter.py +0 -0
  67. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_prompting.py +0 -0
  68. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_stream_adapter.py +0 -0
  69. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_binding.py +0 -0
  70. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_loop.py +0 -0
  71. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_rendering.py +0 -0
  72. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_resolution.py +0 -0
  73. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_tool_utils.py +0 -0
  74. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/react/react_tracing.py +0 -0
  75. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_context.py +0 -0
  76. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_support/__init__.py +0 -0
  77. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_support/checkpoints.py +0 -0
  78. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_support/model_metadata.py +0 -0
  79. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_support/request_context_helpers.py +0 -0
  80. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_support/sql_checkpointer.py +0 -0
  81. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/runtime_support/user_token_refresher.py +0 -0
  82. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/support/__init__.py +0 -0
  83. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/support/filesystem_context.py +0 -0
  84. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/support/thinking.py +0 -0
  85. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/support/tool_approval.py +0 -0
  86. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime/support/tool_loop.py +0 -0
  87. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime.egg-info/SOURCES.txt +0 -0
  88. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime.egg-info/dependency_links.txt +0 -0
  89. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime.egg-info/entry_points.txt +0 -0
  90. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime.egg-info/requires.txt +0 -0
  91. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/fred_runtime.egg-info/top_level.txt +0 -0
  92. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/setup.cfg +0 -0
  93. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_client.py +0 -0
  94. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_config_loader.py +0 -0
  95. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_context.py +0 -0
  96. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_context_aware_tool.py +0 -0
  97. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_conversational_memory.py +0 -0
  98. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_eval_collector.py +0 -0
  99. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_eval_trace.py +0 -0
  100. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_fred_workspace_fs.py +0 -0
  101. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_graph_runtime_invoke_agent.py +0 -0
  102. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_graph_runtime_observability.py +0 -0
  103. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_history.py +0 -0
  104. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_kf_vector_search_toolkit.py +0 -0
  105. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_kf_workspace_client.py +0 -0
  106. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_kpi_display.py +0 -0
  107. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_mcp_config.py +0 -0
  108. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_model_routing.py +0 -0
  109. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_openai_compat_router.py +0 -0
  110. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_pod_client.py +0 -0
  111. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_react_prompting.py +0 -0
  112. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_repl_helpers.py +0 -0
  113. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_runtime_context_prompt_injection.py +0 -0
  114. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_smoke.py +0 -0
  115. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_sql_checkpointer_owner.py +0 -0
  116. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_token_expiry.py +0 -0
  117. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_url_helpers.py +0 -0
  118. {fred_runtime-3.3.1 → fred_runtime-3.3.2}/tests/test_user_token_refresher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 3.3.1
3
+ Version: 3.3.2
4
4
  Summary: Runtime adapters and infrastructure wiring for Fred v2 agents.
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.0
@@ -56,6 +56,7 @@ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
56
56
  from fastapi.middleware.cors import CORSMiddleware
57
57
  from fastapi.responses import StreamingResponse
58
58
  from fred_core.common.config_loader import get_config
59
+ from fred_core.common.team_id import is_personal_team_id, personal_team_id
59
60
  from fred_core.history.history_schema import ChatMessage
60
61
  from fred_core.kpi import KPIMiddleware
61
62
  from fred_core.kpi.kpi_writer_structures import KPIActor
@@ -1127,6 +1128,12 @@ async def _authorize_execution_or_raise(
1127
1128
  - security disabled (no authenticated user) → skip (dev/local).
1128
1129
  - ReBAC engine absent or disabled (Noop) → skip (identity-only dev posture);
1129
1130
  the C3 profile guarantees an enabled engine in classified deployments.
1131
+ - a personal space (`personal-<uid>`) is intrinsic ownership, not a stored
1132
+ OpenFGA tuple: a human caller acting on their own canonical
1133
+ `personal_team_id(authenticated_user.uid)` is authorized without an OpenFGA
1134
+ call; any other `personal-*` id (another user's space, or the bare
1135
+ `"personal"` alias) is explicitly denied — never routed to OpenFGA (AUTHZ-05
1136
+ item 8b watch item).
1130
1137
  - otherwise require the caller to hold `CAN_READ` on the requested team — the
1131
1138
  same relation the control-plane required before it would mint a grant. The
1132
1139
  team is caller-supplied but safe: OpenFGA only authorizes teams the user
@@ -1202,6 +1209,36 @@ async def _authorize_execution_or_raise(
1202
1209
  agent_instance_id=request.agent_instance_id,
1203
1210
  )
1204
1211
  return
1212
+ if team_id == "personal" or is_personal_team_id(team_id):
1213
+ # Personal spaces are intrinsic ownership (AUTHZ-05 item 8b): the id is
1214
+ # derived from the JWT subject via `personal_team_id`, is never persisted
1215
+ # as an OpenFGA tuple, and must never be resolved by OpenFGA. Only an
1216
+ # exact match against the caller's own canonical id is authorized; any
1217
+ # other personal-* id (another user's space) or the bare "personal"
1218
+ # alias (ambiguous once ReBAC is active) is denied here, before OpenFGA
1219
+ # ever sees it.
1220
+ if team_id == personal_team_id(authenticated_user.uid):
1221
+ _emit_audit_event(
1222
+ container,
1223
+ "info",
1224
+ "personal_space_owner_authorized",
1225
+ user_id=authenticated_user.uid,
1226
+ team_id=team_id,
1227
+ agent_instance_id=request.agent_instance_id,
1228
+ )
1229
+ return
1230
+ _emit_audit_event(
1231
+ container,
1232
+ "warning",
1233
+ "personal_space_denied",
1234
+ user_id=authenticated_user.uid,
1235
+ team_id=team_id,
1236
+ agent_instance_id=request.agent_instance_id,
1237
+ )
1238
+ raise HTTPException(
1239
+ status_code=status.HTTP_403_FORBIDDEN,
1240
+ detail=f"user {authenticated_user.uid!r} is not authorized for team {team_id!r}",
1241
+ )
1205
1242
  try:
1206
1243
  await rebac.check_user_team_permission_or_raise(
1207
1244
  authenticated_user, TeamPermission.CAN_READ, team_id
@@ -2054,7 +2091,6 @@ async def _iterate_runtime_event_payloads(
2054
2091
  checkpoint_id=resolved_checkpoint_id,
2055
2092
  user_id=ctx.get("user_id"),
2056
2093
  team_id=resolved_team_id,
2057
- user_groups=ctx.get("user_groups"),
2058
2094
  language=ctx.get("language"),
2059
2095
  access_token=access_token,
2060
2096
  refresh_token=ctx.get("refresh_token"),
@@ -2258,11 +2294,19 @@ def _build_agent_router(
2258
2294
  - developers need to validate KPI emission from the CLI without
2259
2295
  Grafana or Prometheus; this exposes the pod-local ring buffer
2260
2296
  - max 200 entries retained in memory (oldest evicted automatically)
2297
+
2298
+ AUTHZ-05 review finding: item 8a's org-level CAN_READ_METRICS removal
2299
+ does not apply here — unlike the tier-2 capability it replaced
2300
+ (near-universal, protected nothing specific), this buffer holds
2301
+ cross-user/cross-team data (session_id, exchange_id, user_id, team_id,
2302
+ token counts) for every caller that has hit this pod, not just the
2303
+ caller's own. Gated the same way as the sibling `get_audit_events`
2304
+ below, on `CAN_MANAGE_PLATFORM`, not deleted outright.
2261
2305
  """
2262
2306
  rebac = get_runtime_context().config.rebac_engine
2263
2307
  if caller is not None and rebac is not None and rebac.enabled:
2264
2308
  await rebac.check_user_permission_or_raise(
2265
- caller, OrganizationPermission.CAN_READ_METRICS, ORGANIZATION_ID
2309
+ caller, OrganizationPermission.CAN_MANAGE_PLATFORM, ORGANIZATION_ID
2266
2310
  )
2267
2311
  with container._kpi_turns_lock:
2268
2312
  events = list(container.kpi_turns_buffer)
@@ -2991,8 +3035,10 @@ def _build_agent_router(
2991
3035
  stream closes. Do not rely on it as an end-of-turn signal here.
2992
3036
 
2993
3037
  Security:
2994
- - For managed execution (agent_instance_id), an execution_grant issued by
2995
- control-plane is required. The runtime validates it structurally.
3038
+ - There is no ExecutionGrant. Identity is the caller's Keycloak JWT; for
3039
+ managed execution (agent_instance_id) the runtime authorizes each request
3040
+ itself with a per-request ReBAC (OpenFGA) check on runtime_context.team_id.
3041
+ The control plane issues no signed grant or capability token.
2996
3042
  - RBAC via Keycloak and REBAC via OpenFGA protect this endpoint.
2997
3043
 
2998
3044
  Architectural note:
@@ -18,8 +18,9 @@ from __future__ import annotations
18
18
 
19
19
  import asyncio
20
20
  import logging
21
+ import time
21
22
  from collections.abc import Awaitable, Iterable
22
- from typing import Any, Callable, List, Optional, cast
23
+ from typing import Any, Callable, List, Optional, Tuple, cast
23
24
 
24
25
  from fred_sdk.contracts.context import RuntimeContext as AgentRuntimeContext
25
26
  from fred_sdk.contracts.models import (
@@ -45,6 +46,105 @@ logger = logging.getLogger(__name__)
45
46
  MCP_CONNECT_MAX_ATTEMPTS = 3
46
47
  MCP_CONNECT_RETRY_BASE_DELAY_SECS = 0.5
47
48
 
49
+ # --- Cross-request connect/tool-list cache (perf) -----------------------------
50
+ # BENCH-<pending id>: `_run_lifecycle` used to reconnect + re-list tools against
51
+ # every configured MCP server on EVERY agent turn (measured: ~2-3s wall time for
52
+ # a 7-server agent, with a near-instant mock LLM). `MultiServerMCPClient` itself
53
+ # holds no persistent resources (see `_close_mcp_client_quietly` below), so the
54
+ # per-turn cost was purely the repeated connect+list_tools round trips, not
55
+ # anything a "closed" connection would have saved by staying open.
56
+ #
57
+ # This cache reuses the (client, tools) pair across turns for the SAME agent +
58
+ # server set + access token, so repeat turns in a session (or across sessions
59
+ # for the same user/dev token) skip the round trips entirely.
60
+ #
61
+ # Auth freshness on a cache hit: `langchain_mcp_adapters` reads `tool_interceptors`
62
+ # (and per-server `headers`) live off the client instance at call time, not from a
63
+ # snapshot frozen at discovery time (verified against langchain_mcp_adapters==0.3.0
64
+ # — see `execute_tool`/`_build_interceptor_chain` in its `tools.py`). So on a cache
65
+ # hit we mutate `client.tool_interceptors` IN PLACE (never reassign the list) to the
66
+ # CURRENT request's own interceptors before handing the client back — every request
67
+ # still gets its own live `ExpiredTokenRetryInterceptor` (bound to ITS OWN, live
68
+ # `agent_instance`/token-refresh callback), even though the underlying connection +
69
+ # discovered tool schemas are shared. Headers don't need the same treatment: the
70
+ # cache key already includes the exact access_token string, so a cache hit implies
71
+ # an identical token/header value already.
72
+ #
73
+ # Residual, accepted race: two truly concurrent requests for the SAME identity
74
+ # (same cache key) could have request A's in-flight tool call pick up request B's
75
+ # interceptor instance instead of its own. Both point at the same live user, so
76
+ # this is never a cross-user leak — at worst a harmless same-identity mix-up.
77
+ # Closing it fully would need a lock held for an entire turn's tool-calling phase,
78
+ # which would serialize concurrent turns for the same user; not worth it.
79
+ MCP_CLIENT_CACHE_TTL_SECS = 300.0
80
+ _mcp_client_cache: dict[
81
+ Tuple[str, Tuple[str, ...], str], Tuple[MultiServerMCPClient, List[BaseTool], float]
82
+ ] = {}
83
+ _mcp_client_cache_lock = asyncio.Lock()
84
+
85
+
86
+ def _mcp_cache_key(
87
+ agent_id: str, servers: List[MCPServerConfiguration], access_token: str | None
88
+ ) -> Tuple[str, Tuple[str, ...], str]:
89
+ return (agent_id, tuple(sorted(s.id for s in servers)), access_token or "")
90
+
91
+
92
+ def _prune_expired_mcp_cache_locked(now: float) -> None:
93
+ """
94
+ Drop every expired entry from `_mcp_client_cache`.
95
+
96
+ Why this exists:
97
+ - a cache hit only checks the ONE key being looked up; without this, any
98
+ OTHER key that expires (e.g. a rotated user token, an agent that stops
99
+ being used) stays resident in process memory forever — a real memory
100
+ leak over a long-running pod's life, and it keeps stale `Authorization`
101
+ headers (the cache key embeds the raw access_token) around indefinitely.
102
+
103
+ How to use it:
104
+ - call under `_mcp_client_cache_lock`, before a lookup or insert.
105
+ """
106
+ expired = [k for k, v in _mcp_client_cache.items() if v[2] <= now]
107
+ for k in expired:
108
+ del _mcp_client_cache[k]
109
+
110
+
111
+ async def _get_or_connect_mcp_client(
112
+ *,
113
+ agent_id: str,
114
+ mcp_servers: List[MCPServerConfiguration],
115
+ runtime_context: AgentRuntimeContext,
116
+ tool_interceptors: list,
117
+ ) -> Tuple[MultiServerMCPClient, List[BaseTool]]:
118
+ key = _mcp_cache_key(agent_id, mcp_servers, runtime_context.access_token)
119
+ now = time.monotonic()
120
+
121
+ async with _mcp_client_cache_lock:
122
+ _prune_expired_mcp_cache_locked(now)
123
+ cached = _mcp_client_cache.get(key)
124
+ if cached is not None and cached[2] > now:
125
+ cached_client = cached[0]
126
+ # In-place mutation only — reassigning `.tool_interceptors` would not
127
+ # be seen by tool closures already vended from a prior get_tools()
128
+ # call (they hold a reference to the original list object).
129
+ cached_client.tool_interceptors[:] = tool_interceptors
130
+ logger.info(
131
+ "[MCP] agent=%s cache hit: reusing connected client (%d tools), "
132
+ "current request's interceptors swapped in.",
133
+ agent_id,
134
+ len(cached[1]),
135
+ )
136
+ return cached_client, cached[1]
137
+
138
+ client, tools = await get_connected_mcp_client_for_agent(
139
+ agent_id=agent_id,
140
+ mcp_servers=mcp_servers,
141
+ runtime_context=runtime_context,
142
+ tool_interceptors=tool_interceptors,
143
+ )
144
+ async with _mcp_client_cache_lock:
145
+ _mcp_client_cache[key] = (client, tools, now + MCP_CLIENT_CACHE_TTL_SECS)
146
+ return client, tools
147
+
48
148
 
49
149
  async def _close_mcp_client_quietly(client: Optional[MultiServerMCPClient]) -> None:
50
150
  if not client:
@@ -245,29 +345,23 @@ class MCPRuntime:
245
345
  if refresh_cb:
246
346
  interceptors.append(ExpiredTokenRetryInterceptor(refresh_cb))
247
347
 
248
- new_client = await get_connected_mcp_client_for_agent(
348
+ new_client, tools = await _get_or_connect_mcp_client(
249
349
  agent_id=self._agent_id,
250
350
  mcp_servers=self.remote_servers,
251
351
  runtime_context=runtime_context,
252
352
  tool_interceptors=interceptors,
253
353
  )
254
354
  self.mcp_client = new_client
355
+ # `tools` were already fetched (per server) while connecting/validating
356
+ # above — reuse them instead of paying for a second get_tools() round
357
+ # trip against every server on every single turn.
255
358
  self.toolkit = McpToolkit(client=new_client, agent=self.agent_instance)
256
- try:
257
- # Pre-fetch tools once (async) and cache in toolkit for sync callers
258
- tools = await new_client.get_tools()
259
- self.toolkit.tools = tools
260
- logger.info(
261
- "[MCP] agent=%s init: Prefetched and cached %d tools.",
262
- self._agent_id,
263
- len(tools),
264
- )
265
- except Exception:
266
- logger.warning(
267
- "[MCP] agent=%s init: Failed to prefetch tools; toolkit will attempt best-effort discovery later.",
268
- self._agent_id,
269
- exc_info=True,
270
- )
359
+ self.toolkit.tools = tools
360
+ logger.info(
361
+ "[MCP] agent=%s init: Connected and cached %d tools.",
362
+ self._agent_id,
363
+ len(tools),
364
+ )
271
365
  logger.info(
272
366
  "[MCP] agent=%s init: Successfully built and connected client.",
273
367
  self._agent_id,
@@ -25,7 +25,8 @@ Contract
25
25
  --------
26
26
  - Requires an `access_token_provider` (a callable) to fetch the user's token
27
27
  before connecting to any MCP server.
28
- - Returns a connected `MultiServerMCPClient`.
28
+ - Returns a connected `MultiServerMCPClient` plus the tools already fetched
29
+ while validating each server (see `get_connected_mcp_client_for_agent`).
29
30
  - Raises `ExceptionGroup` if **any** server fails to connect.
30
31
 
31
32
  """
@@ -35,10 +36,11 @@ from __future__ import annotations
35
36
  import logging
36
37
  import time
37
38
  from datetime import timedelta
38
- from typing import Dict, List
39
+ from typing import Dict, List, Tuple
39
40
 
40
41
  from fred_sdk.contracts.context import RuntimeContext
41
42
  from fred_sdk.contracts.models import MCPServerConfiguration
43
+ from langchain_core.tools import BaseTool
42
44
  from langchain_mcp_adapters.client import MultiServerMCPClient
43
45
  from langchain_mcp_adapters.sessions import Connection, StreamableHttpConnection
44
46
 
@@ -184,10 +186,14 @@ async def get_connected_mcp_client_for_agent(
184
186
  *,
185
187
  tool_interceptors: list | None = None,
186
188
  # -----------------------------------------------
187
- ) -> MultiServerMCPClient:
189
+ ) -> Tuple[MultiServerMCPClient, List[BaseTool]]:
188
190
  """
189
191
  Creates and connects the MultiServerMCPClient using the token provided by
190
192
  `access_token_provider`. Supports `streamable_http` and `stdio` transports.
193
+
194
+ Also returns the tools fetched per server during connection validation, so
195
+ callers don't have to pay for a second `get_tools()` round trip against
196
+ every server just to populate their toolkit (see mcp_runtime.py).
191
197
  """
192
198
 
193
199
  for s in mcp_servers:
@@ -262,10 +268,13 @@ async def get_connected_mcp_client_for_agent(
262
268
  connections, tool_interceptors=tool_interceptors or []
263
269
  )
264
270
 
265
- # Validate connections by attempting to load tools per server
271
+ # Validate connections by attempting to load tools per server. This is also
272
+ # the ONLY tool-fetch round trip we make per server — the fetched tools are
273
+ # returned to the caller so `MCPRuntime._run_lifecycle` doesn't need to call
274
+ # `client.get_tools()` again across all servers right after this returns.
266
275
  exceptions: list[Exception] = []
267
276
  failure_messages: list[str] = []
268
- total_tools = 0
277
+ fetched_tools: list[BaseTool] = []
269
278
  for server in mcp_servers:
270
279
  conn_entry = connections.get(server.id) or {}
271
280
  transport = conn_entry.get("transport", "unknown")
@@ -294,7 +303,7 @@ async def get_connected_mcp_client_for_agent(
294
303
  len(tools),
295
304
  dur_ms,
296
305
  )
297
- total_tools += len(tools)
306
+ fetched_tools.extend(tools)
298
307
  except Exception as e:
299
308
  dur_ms = (time.perf_counter() - start) * 1000
300
309
  logger.warning(
@@ -328,6 +337,6 @@ async def get_connected_mcp_client_for_agent(
328
337
  logger.debug(
329
338
  "[MCP][%s] summary: all servers validated, total tools=%d",
330
339
  agent_id,
331
- total_tools,
340
+ len(fetched_tools),
332
341
  )
333
- return client
342
+ return client, fetched_tools
@@ -1906,7 +1906,6 @@ def _graph_phase_timer(
1906
1906
  return metrics.timer(
1907
1907
  "app.phase_latency_ms",
1908
1908
  dims=dims,
1909
- groups=binding.runtime_context.user_groups,
1910
1909
  )
1911
1910
 
1912
1911
 
@@ -1903,11 +1903,10 @@ class KPIWriterMetricsAdapter(MetricsProvider):
1903
1903
  name: str,
1904
1904
  *,
1905
1905
  dims: dict[str, str | None] | None = None,
1906
- groups: list[str] | None = None,
1907
1906
  ) -> Generator[dict[str, str | None], None, None]:
1908
1907
  with self._kpi.timer(
1909
1908
  name,
1910
1909
  dims=dims,
1911
- actor=KPIActor(type="system", groups=groups),
1910
+ actor=KPIActor(type="system"),
1912
1911
  ) as recorded_dims:
1913
1912
  yield recorded_dims
@@ -322,7 +322,6 @@ class _TransportBackedReActExecutor(Executor[ReActInput, ReActOutput]):
322
322
  "agent_instance_id": baggage.get("agent_instance_id"),
323
323
  "template_agent_id": baggage.get("template_agent_id"),
324
324
  },
325
- groups=self._binding.runtime_context.user_groups,
326
325
  )
327
326
  else:
328
327
  phase_timer_ctx = nullcontext()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-runtime
3
- Version: 3.3.1
3
+ Version: 3.3.2
4
4
  Summary: Runtime adapters and infrastructure wiring for Fred v2 agents.
5
5
  Author-email: Thales <noreply@thalesgroup.com>
6
6
  License: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fred-runtime"
3
- version = "3.3.1"
3
+ version = "3.3.2"
4
4
  description = "Runtime adapters and infrastructure wiring for Fred v2 agents."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -29,8 +29,10 @@ from typing import cast
29
29
 
30
30
  import pytest
31
31
  from conftest import StaticChatModelFactory, ToolFriendlyFakeChatModel
32
+ from fastapi.routing import APIRoute
32
33
  from fastapi.testclient import TestClient
33
34
  from fred_core.common.config_loader import get_config
35
+ from fred_core.common.team_id import personal_team_id
34
36
  from fred_core.kpi.kpi_writer import KPIWriter
35
37
  from fred_core.kpi.log_kpi_store import KpiLogStore
36
38
  from fred_core.kpi.prometheus_kpi_store import PrometheusKPIStore
@@ -1693,7 +1695,7 @@ def _wire_engine(
1693
1695
  )
1694
1696
 
1695
1697
 
1696
- _ALICE = KeycloakUser(uid="alice", username="alice", roles=[], email=None, groups=[])
1698
+ _ALICE = KeycloakUser(uid="alice", username="alice", roles=[], email=None)
1697
1699
 
1698
1700
 
1699
1701
  @pytest.mark.asyncio
@@ -1824,7 +1826,6 @@ _WORKER = KeycloakUser(
1824
1826
  username="service-account-fred-evaluation-worker",
1825
1827
  roles=["service_agent"],
1826
1828
  email=None,
1827
- groups=[],
1828
1829
  )
1829
1830
 
1830
1831
 
@@ -1869,6 +1870,72 @@ async def test_authorize_service_agent_still_requires_team(
1869
1870
  assert engine.calls == []
1870
1871
 
1871
1872
 
1873
+ _BOB = KeycloakUser(uid="bob", username="bob", roles=[], email=None)
1874
+
1875
+
1876
+ @pytest.mark.asyncio
1877
+ async def test_authorize_allows_personal_space_owner_without_openfga(
1878
+ monkeypatch, minimal_config
1879
+ ) -> None:
1880
+ """A human caller acting on their own canonical personal_team_id is authorized
1881
+ as intrinsic ownership — no OpenFGA call (AUTHZ-05 item 8b watch item)."""
1882
+ engine = _FakeRebacEngine(enabled=True, deny=True) # would deny if consulted
1883
+ _wire_engine(monkeypatch, engine)
1884
+ container = PodApplicationContext(minimal_config)
1885
+
1886
+ await agent_app_module._authorize_execution_or_raise(
1887
+ _managed_request(team_id=personal_team_id(_ALICE.uid)), _ALICE, container
1888
+ )
1889
+
1890
+ assert engine.calls == []
1891
+ with container._audit_events_lock:
1892
+ events = list(container.audit_events_buffer)
1893
+ assert events[-1]["audit_event"] == "personal_space_owner_authorized"
1894
+ assert events[-1].get("team_id") == personal_team_id(_ALICE.uid)
1895
+
1896
+
1897
+ @pytest.mark.asyncio
1898
+ async def test_authorize_denies_other_users_personal_space(
1899
+ monkeypatch, minimal_config
1900
+ ) -> None:
1901
+ """Alice requesting Bob's personal space is denied outright — a permissive
1902
+ (or residual) OpenFGA tuple must never be able to rescue this."""
1903
+ engine = _FakeRebacEngine(enabled=True, deny=False) # would allow if consulted
1904
+ _wire_engine(monkeypatch, engine)
1905
+ container = PodApplicationContext(minimal_config)
1906
+
1907
+ with pytest.raises(agent_app_module.HTTPException) as exc:
1908
+ await agent_app_module._authorize_execution_or_raise(
1909
+ _managed_request(team_id=personal_team_id(_BOB.uid)), _ALICE, container
1910
+ )
1911
+
1912
+ assert exc.value.status_code == 403
1913
+ assert engine.calls == []
1914
+ with container._audit_events_lock:
1915
+ events = list(container.audit_events_buffer)
1916
+ assert events[-1]["audit_event"] == "personal_space_denied"
1917
+ assert events[-1].get("user_id") == "alice"
1918
+
1919
+
1920
+ @pytest.mark.asyncio
1921
+ async def test_authorize_denies_ambiguous_personal_alias(
1922
+ monkeypatch, minimal_config
1923
+ ) -> None:
1924
+ """The bare "personal" alias is ambiguous once ReBAC is active — reject it
1925
+ rather than resolving it as if it meant the caller's own space."""
1926
+ engine = _FakeRebacEngine(enabled=True, deny=False)
1927
+ _wire_engine(monkeypatch, engine)
1928
+ container = PodApplicationContext(minimal_config)
1929
+
1930
+ with pytest.raises(agent_app_module.HTTPException) as exc:
1931
+ await agent_app_module._authorize_execution_or_raise(
1932
+ _managed_request(team_id="personal"), _ALICE, container
1933
+ )
1934
+
1935
+ assert exc.value.status_code == 403
1936
+ assert engine.calls == []
1937
+
1938
+
1872
1939
  # ---------------------------------------------------------------------------
1873
1940
  # RUNTIME-07 rev. 2 (F-B / F-C) — JWT identity + private-per-owner sessions
1874
1941
  # ---------------------------------------------------------------------------
@@ -1964,7 +2031,9 @@ async def test_session_ownership_skipped_when_security_disabled(
1964
2031
 
1965
2032
 
1966
2033
  class _FakePlatformRebacEngine:
1967
- """RebacEngine stand-in exposing has_user_permission for the C1 admin branch."""
2034
+ """RebacEngine stand-in exposing has_user_permission for the C1 admin branch,
2035
+ plus check_user_permission_or_raise for the /kpi-turns and /audit-events
2036
+ ring-buffer endpoints' CAN_MANAGE_PLATFORM gate."""
1968
2037
 
1969
2038
  def __init__(self, *, enabled: bool, grant: bool) -> None:
1970
2039
  self._enabled = enabled
@@ -1981,6 +2050,13 @@ class _FakePlatformRebacEngine:
1981
2050
  self.calls.append((user.uid, permission, resource_id))
1982
2051
  return self._grant
1983
2052
 
2053
+ async def check_user_permission_or_raise(
2054
+ self, user: KeycloakUser, permission: object, resource_id: str, **_kw: object
2055
+ ) -> None:
2056
+ self.calls.append((user.uid, permission, resource_id))
2057
+ if not self._grant:
2058
+ raise AuthorizationError(user.uid, str(permission), Resource.RESOURCES)
2059
+
1984
2060
 
1985
2061
  @pytest.mark.asyncio
1986
2062
  async def test_caller_can_manage_platform_true_when_enabled_and_granted(
@@ -2027,6 +2103,59 @@ async def test_caller_can_manage_platform_false_when_no_caller(monkeypatch) -> N
2027
2103
  assert engine.calls == []
2028
2104
 
2029
2105
 
2106
+ def _get_kpi_turns_endpoint():
2107
+ """Grab the `/kpi-turns` route's raw endpoint function, bypassing FastAPI's
2108
+ dependency-injection layer so it can be called directly with explicit args."""
2109
+ router = agent_app_module._build_agent_router(registry={}, security_enabled=True)
2110
+ return next(
2111
+ route.endpoint
2112
+ for route in router.routes
2113
+ if isinstance(route, APIRoute) and route.endpoint.__name__ == "get_kpi_turns"
2114
+ )
2115
+
2116
+
2117
+ @pytest.mark.asyncio
2118
+ async def test_get_kpi_turns_requires_can_manage_platform_when_enforcing(
2119
+ monkeypatch, minimal_config
2120
+ ) -> None:
2121
+ """AUTHZ-05 review finding: item 8a's blanket removal of the org-level
2122
+ CAN_READ_METRICS check does not apply to this ring buffer — it exposes
2123
+ cross-user/cross-team session_id/user_id/team_id/token data for every
2124
+ caller that has hit this pod, unlike the tier-2 capabilities item 8a
2125
+ correctly deleted elsewhere. Gated on CAN_MANAGE_PLATFORM, same as the
2126
+ sibling `get_audit_events`, when ReBAC is actually enforcing."""
2127
+ endpoint = _get_kpi_turns_endpoint()
2128
+ container = PodApplicationContext(minimal_config)
2129
+
2130
+ denying_engine = _FakePlatformRebacEngine(enabled=True, grant=False)
2131
+ _wire_engine(monkeypatch, denying_engine)
2132
+ with pytest.raises(AuthorizationError):
2133
+ await endpoint(limit=10, container=container, caller=_ALICE)
2134
+ assert denying_engine.calls == [
2135
+ ("alice", OrganizationPermission.CAN_MANAGE_PLATFORM, ORGANIZATION_ID)
2136
+ ]
2137
+
2138
+ granting_engine = _FakePlatformRebacEngine(enabled=True, grant=True)
2139
+ _wire_engine(monkeypatch, granting_engine)
2140
+ assert await endpoint(limit=10, container=container, caller=_ALICE) == []
2141
+
2142
+
2143
+ @pytest.mark.asyncio
2144
+ async def test_get_kpi_turns_unchanged_in_dev_mode(monkeypatch, minimal_config) -> None:
2145
+ """Dev/no-security mode (no caller, or ReBAC disabled) stays exactly as
2146
+ before this fix: the diagnostic buffer remains reachable without a grant."""
2147
+ endpoint = _get_kpi_turns_endpoint()
2148
+ container = PodApplicationContext(minimal_config)
2149
+
2150
+ _wire_engine(monkeypatch, None)
2151
+ assert await endpoint(limit=10, container=container, caller=None) == []
2152
+
2153
+ disabled_engine = _FakePlatformRebacEngine(enabled=False, grant=False)
2154
+ _wire_engine(monkeypatch, disabled_engine)
2155
+ assert await endpoint(limit=10, container=container, caller=_ALICE) == []
2156
+ assert disabled_engine.calls == [] # a disabled engine is never consulted
2157
+
2158
+
2030
2159
  @pytest.mark.asyncio
2031
2160
  async def test_identity_is_stamped_from_jwt_and_body_tokens_neutralized(
2032
2161
  monkeypatch, minimal_config
@@ -332,7 +332,7 @@ class _FakePortable:
332
332
 
333
333
 
334
334
  class _FakeRuntimeContext:
335
- user_groups: tuple[str, ...] = ()
335
+ pass
336
336
 
337
337
 
338
338
  class _FakeBinding:
File without changes
File without changes