Fase E: per-task/project/session connector allowlists, enforced in the real
dispatcher (not just hidden in the prompt), plus an honest tool-support
signal. F2 does not touch src/connectors.py / src/connector_sidecar.py /
routes/connector_routes.py (Lote F1) — this lot's policy works purely off
McpServer.id, already present in core/database.py before F1 or F2 existed.
Read before writing a line of F2 code, as required by the contract. Line numbers are from this worktree at the time F2 was implemented; they will drift as the file changes, but the function names are stable anchors.
- System prompt / schema assembly —
src/agent_loop.py::_build_system_prompt(def at line 3014). It callsmcp_mgr.get_all_openai_schemas(mcp_disabled_map or {})at (was) line 3082 to get every MCP tool's OpenAI-style schema, then hands the result (plus the RAG-narrowedrelevant_toolsset and the base prompt from_build_base_prompt) back to its caller. F2.3's schema filter is inserted immediately after that call (see §Enforcement below). - Per-server disabled-tool map —
_load_mcp_disabled_map(line 381) readsMcpServer.disabled_tools(a JSON array of tool names to hide, admin-set per server) into{server_id: {tool_name, ...}}. This is a different axis from F2's connector allowlist: it hides individual tools within an otherwise-reachable server, never a whole server. F2.3 never touches this map — it filters the schema listget_all_openai_schemasalready produced, one layer downstream. - Tool-RAG —
src/tool_index.py.ToolIndex.index_mcp_tools(line 407) indexes every MCP tool's description globally (keyed by generation, not by session), so a session's connector restriction is invisible to RAG retrieval —get_tools_for_querycan and does nominate a tool name from a server this turn may not call. This is harmless:_tool_schemas_for_route(agent_loop.py, ~6978) only ever offers a schema that is BOTH inroute_relevant_tools(RAG's opinion) AND inroute_mcp_schemas(already connector-filtered) — a RAG hit with no matching schema is silently dropped, same as any other tool RAG nominates that got disabled for other reasons. F2 does not changetool_index.py. - Dispatcher —
src/tool_execution.py::execute_tool_block(line 1073, thin wrapper) →_execute_tool_block_impl(line 1334, does the real work). This is the ONE place every tool call passes through regardless of how it got there (native function-calling, fenced-block text parsing, a_MCP_TOOL_MAP-routed legacy name via_call_mcp_toolat line 741, or a bareBUILTIN_EMAIL_TOOLSname). F2.3's enforcement (see below) sits right after the existingdisabled_tools/tool_policyblocks, before any admin/public-tool gate. - Scheduler /
task_policies—src/task_scheduler.py.task_policiesis its OWN sqlite file (_TASK_POLICY_DB, a sibling tocore/database.py's main DB — see the module comment above_policy_connect, ~line 150), not a column onScheduledTask.set_task_policy/get_task_policy(originally ~200/263, now a little further down after F2.2's addition) are the whole public surface.TaskScheduler._execute_llm_task(~2246) is where a scheduled task'sdisabled_toolsgets composed today: the crew'senabled_toolsallowlist (~2347) inverted intodisabled_tools, the task's own declaredpermissions(AUTO-02) folded in as a further ceiling, then the globaldisabled_toolssetting. This is a plain tool-name denylist — it has no concept of an MCP server id, which is exactly the gap F2.2/F2.3 fill formcp__<server>__<tool>names._execute_llm_taskalso creates (once) the task's own dedicated session —task.session_id— and every run after that reuses it;_run_agent_looppasses that samesession_idintostream_agent_loop(→_stream_agent_loop_body→_build_system_promptandexecute_tool_block). - Projects —
services/projects.py.ProjectStore(class at line 268 in the pre-F2 file) persists todata/projects.json;_validate(identity fields only: name/folder/workspace/instructions) andupdate()'sAGENT_OPTION_FIELDSloop (the pattern for "additive per-project knob, absent key = untouched") are the two things F2.2 mirrors for the newconnectorsfield (added next to theAGENT_OPTION_FIELDSloop, not inside_validateitself — see §Decisions).project_for_session/project_context_for_session(_resolve_project_for_session, ~1470) resolve a chat's project fromSession.project_idfirst, the legacy folder match second — F2 reads through the same function, so a project bound either way is picked up identically. Session.behavior_modemigration (the pattern F2.2 replicates forSession.connector_ids) —core/database.py: the column itself is declared inline on theSessionmodel (originally line 238, right aftercrew_member_id), a dedicated idempotent migration function_migrate_add_session_behavior_modechecksPRAGMA table_info(sessions)and doesALTER TABLE sessions ADD COLUMN behavior_mode TEXTonly if the column is missing (guards a pre-existingsessionstable; a freshcreate_allalready has the column and the ALTER is skipped), and the function is registered by name in_formal_migration_steps()'s list (("add_session_behavior_mode", _migrate_add_session_behavior_mode), originally the last entry). Paired getter/setter (get_session_behavior_mode/set_session_behavior_mode) route throughget_db_session()and are best-effort (log + returnNone/False, never raise). F2.2'sSession.connector_ids/_migrate_add_session_connector_ids/get_session_connector_ids/set_session_connector_idsfollow this exact shape.- Resumption / background —
GET /api/chat/resume/{session_id}(routes/chat_routes.py, ~4084) just re-subscribes (agent_runs.subscribe) to an already-running detached run keyed by the SAMEsession_idthe run was started with; it does not rebuild the tool offer or re-enter the dispatcher with new context. The run itself (theasyncio.Taskcreated byPOST /api/chat_stream/_run_detached, not shown by/resume) keeps calling_build_system_prompteach round andexecute_tool_blockfor each tool call, both already carryingsession_id— see §Decisions for why this means resumption needs no separate wiring for F2.src/crash_recovery.py::resume_plan(line 434) is unrelated — it resumes a changeset/edit-cluster plan after a crash, not a chat or task's tool context, and is not part of this lot's scope. Session/McpServer/ScheduledTaskmodels —core/database.py:Session(line 174),McpServer(line 1119, pre-existing — reused per-contract, never redefined here),ScheduledTask(line 1302, pre-existing, hassession_id— the FK F2 relies on to find "which task is this session's own working session").
connector_ids: list[str] | None at three tiers, each an McpServer.id
list:
| Tier | Storage | Field |
|---|---|---|
| Session | sessions.connector_ids (new column, TEXT/JSON) |
core.database.get_session_connector_ids / set_session_connector_ids |
| Project | data/projects.json row |
connectors (validated in services/projects.py, see §Decisions) |
| Task | task_policies.connector_ids (new column, TEXT/JSON, sqlite sibling DB) |
src.task_scheduler.get_task_policy(...)["connector_ids"] / set_task_policy(..., connector_ids=...) |
None at a tier = "this tier declared nothing" (today's behavior:
unrestricted). An explicit [] is a distinct, honoured value: "this tier
allows zero connectors" — never silently upgraded to "unrestricted".
Precedence: task/session > project > unrestricted (src.connector_policy .resolve_allowed_servers; task wins over session when, unusually, both are
set — see that function's docstring).
Single module: src/connector_policy.py.
resolve_allowed_servers(session=None, project=None, task=None) -> set[str] | None— pure, no I/O. Exactly the contract's signature; each argument is that tier's OWN already-resolved value (a plainlist[str] | None), not a session id or a lookup key.is_tool_allowed(tool_name, allowed) -> bool— pure.allowed=Nonealways →True. Only gates names shapedmcp__<server_id>__<tool>(parsed withstr.split("__", 2)so a tool segment that itself contains__, e.g. a browser actionmcp__<server>__browser_click, keeps its suffix intact). A name that doesn't start withmcp__, or a malformedmcp__-prefixed name that has no server/tool segment, is let through (True) — see §Decisions for why built-in names are out of scope and why a malformed name is not this function's problem to reject.resolve_allowed_servers_for_session(session_id, owner=None) -> set[str] | None— the one impure helper every real call site uses. Looks up, in order:Session.connector_ids(session tier; short-circuits if notNone);ScheduledTask.session_id == session_id→get_task_policy(task.id) ["connector_ids"](task tier — this is how a scheduled task's declared allowlist is found from nothing but asession_id);project_for_session (session_id, owner)["connectors"](project tier). Feeds the three intoresolve_allowed_servers. Never raises — every sub-lookup is individually best-effort and degrades to "this tier contributed nothing".tool_support_notice(session_id, endpoint_url, owner=None) -> str | None(F2.4) —Noneunlessendpoint_urlis the text-onlyfaustus-cli://transport AND this chat has an explicit connector selection somewhere in its chain. Advisory text only; never touchesendpoint_url/model.
Wired at exactly two points, both already carrying session_id/owner — no
new parameter added to any function between them and the agent loop or the
scheduler (see §Decisions for why):
- Schema visibility —
src/agent_loop.py::_build_system_prompt, right wheremcp_schemas = mcp_mgr.get_all_openai_schemas(...)is computed: filters the list withis_tool_allowedbefore it is ever returned to the caller (i.e. before the tool-RAG/token-budget selection downstream sees it). A resolution failure here fails OPEN (leaves schemas unfiltered, logs a warning) — this is advisory, defense-in-depth; the real boundary is #2. - Execution —
src/tool_execution.py::_execute_tool_block_impl, right after the existingdisabled_tools/tool_policyblocks and before the admin-tool gate: anytoolstarting withmcp__is checked againstresolve_allowed_servers_for_session(session_id, owner); a disallowed call returns{"error": "Connector <server_id> is not enabled for this task", "exit_code": 1}and logs a warning — never an unhandled exception, never executes. This is the actual enforcement boundary: it catches a call "by name" even when its schema was never offered (a model that remembers a tool name from an earlier turn, or guesses one).
Both points resolve fresh from session_id on every call — nothing is
snapshotted into a run. See §Decisions for why this is deliberate and what
it buys for resumption/background/scheduled runs "for free".
GET /api/session/{sid}/connectors,PATCH /api/session/{sid}/connectors(routes/session_routes.py) — body/response{"connector_ids": [...] | null}; GET also returns{"effective": [...] | null, "source": "session" | "project" | "all"}. Deviation from the contract's literal path: the contract saysPATCH /api/sessions/{id}; this file's own convention is singular/session/{sid}for every other single-resource verb (PATCH /session/{sid}already exists for rename/model-switch,GET /session/{sid} /draft, etc. —/sessionsplural is reserved for collection operations likeGET /sessionsandPOST /sessions/bulk-delete). Added as new sub-routes rather than adding aconnector_idsform field to the existingPATCH /session/{sid}(which isForm(...)-based, not JSON, and a list field does not fit that shape) — additive, zero risk to the existing contract (principle 10).GET /api/session/{sid}/tool-support(F2.4) —{"supported": true | false | "unknown", "mode": "api" | "ollama_native" | "ollama_openai_compat" | "fenced" | "text_only" | null, "reason": str}. Read-only; never probes/loads a model."unknown"when the session has no model/endpoint configured yet, or the lookup itself fails.GET /api/projects/{id},PATCH /api/projects/{id}(routes/project_routes.py) —ProjectUpdateRequest.connectors: Optional[List[str]]; both responses are annotated withconnector_ids/effective/source(sourceis only ever"project"or"all"— a project has no tier above it besides unrestricted). Known gap:update_projectreads the body withpayload.model_dump (exclude_none=True), the same as every other field in that model — so a PATCH can setconnectorsto a concrete list or to[], but cannot send an explicitnullto clear a previously-set list back to "no project-level restriction" (indistinguishable from "the caller didn't mention it"). This is a pre-existing limitation of that endpoint's body handling (shared by every other nullable-but-not-boolean field there, e.g.test_command/review_modeluse""as their own "clear" value) and was not changed for one new field — changingexclude_noneglobally for that model was out of scope and riskier than living with the gap.POST /api/tasks,PUT /api/tasks/{task_id}(routes/task/task_routes.py) —TaskCreate.connector_ids/TaskUpdate.connector_ids: Optional[list[str]], written throughset_task_policy(..., connector_ids=...)next to the existing AUTO-02 fields.GET /api/tasks,GET /api/tasks/{id}responses gain aconnectorsobject:{"connector_ids": [...] | null, "effective": [...] | null, "source": "task" | "session" | "project" | "all"}(_task_ to_dict/_task_connectors_payload). Same known gap as projects: PUT cannot explicitly clear a declaredconnector_idsback to "undeclared" through this endpoint (only to a concrete list,[]included) —set_task_policy's new_clear_connector_ids=Truekwarg exists for that case but is not wired to any route yet (documented, not exposed, to avoid inventing a request shape the contract didn't ask for).
- Resolve fresh from
session_id, don't thread a new parameter. The naive reading of F2.3 ("cubrir chat normal, tarea en background, reanudación, tarea programada... el scheduler pasaconnector_idsde la tarea al contexto de ejecución") suggests adding anallowed_connectorsparameter toexecute_tool_block,_execute_tool_block_impl,stream_agent_loop,_stream_agent_loop_body,_run_agent_loop, and every closure in between, then having the scheduler and the chat routes each compute it once and pass it down. That is a lot of signature surface to touch in files this large and this heavily cross-referenced, and every one of those call sites already carriessession_idend-to-end (including the scheduler:task.session_idIS the session the agent loop runs against — see_execute_llm_task). So instead, both enforcement points callresolve_allowed_servers_for_session(session_id, owner)themselves, resolving fresh from persisted state (DB +task_policies.dbprojects.json) on every use. This has three effects, all intentional:
- Zero new parameters on any function between the two enforcement points and their existing callers.
- Resumption and background runs are covered automatically —
GET /api/chat/resume/{id}reconnects to a run that is still callingexecute_tool_block/_build_system_promptwith the samesession_idit always had; there is no separate "resumed" code path to wire. - A live edit takes effect on the very next tool call, not just the
next turn — changing a session's
connector_idsmid-run changes what the very nextmcp__call is allowed to do. This was not explicitly asked for but falls out of "resolve fresh" for free and is a strict improvement over a value snapshotted at turn start. The cost is a few extra DB/file reads per tool call (one sessions-table query, one best-effortScheduledTasklookup, oneprojects.jsonread through the existing in-memoryProjectStorecache) — acceptable given today's tool-call volumes, and every one of those reads was already individually best-effort infrastructure before this lot.
- Minimum scope is
mcp__<server_id>__<tool>names, not built-in tool names. F2.5's own task description hedges this ("y también las built-in de correo si el catálogo las trata como conector — decide y documenta: como mínimo MCP"). Decision: out of scope.is_tool_allowedonly gates names that parse asmcp__<server>__<tool>; a bare built-in name (send_email,list_emails,read_file,bash, ...) — including theBUILTIN_EMAIL_TOOLSbare-name aliases that_execute_tool_block_implinternally re-routes tomcp__email__<tool>— is untouched. Rationale: those are Faustus's own built-in capabilities (not a user-added Hoard-style connector from the F1 catalog), already gated by their own mechanisms (_ADMIN_TOOLS,is_public_blocked_tool, the existingdisabled_tools/ToolPolicymachinery traced above), and folding them into connector policy would silently change what "select a task's connectors" means for every existing task that has never heard of this lot. The one test this decision affects (F2.5: "tarea conconnector_ids=[]no puede llamar mail ... aunque exista") is satisfied for the qualifiedmcp__mail__...(or, in the test suite,mcp__<mail-server-id>__...) spelling, which is the literal minimum the contract asks for; seetest_scheduled_task_empty_connector_ids_blocks_mail_even_though_it_existsintests/test_connector_policy.py. connectorsvalidated inProjectStore.update(), not literally insideProjectStore._validate._validate(identity fields: name/folder/ workspace/instructions) is called from bothcreate()andupdate()and raisesProjectErroron a bad value — the wrong shape for "drop silently invalid list entries, keep the rest, log a warning, never raise" (the contract's own rule for this field).connectorsis handled the same way every other additive per-project knob already is (theAGENT_OPTION_FIELDSloop right next to it inupdate()): absent key = untouched, present key = validated and written (services.projects._sanitize_connector_ids, which queriesMcpServer.idand drops anything not found, keeping order and de-duplicating). Functionally this is still "validated inservices/projects.py, inProjectStore" — just not inside the one method literally named_validate, which was never given aconnectorsparameter to avoid touching its signature/behavior forcreate()too (creation-timeconnectorswas not required by the contract)._agent_route_tool_mode's boolean triad does not itself carry "unsupported".is_api_model/is_native_ollama/ollama_openai_compatonly decide HOW tools are offered (native function-calling schemas vs. Faustus's own fenced-block text instructions) — a model that getsFalsefor all three still gets tools via the fenced mechanism and can use them normally. The ONE case with no tool support at all is the text-only CLI transport (endpoint_urlstartingfaustus-cli://), which_agent_route_tool_modealready special-cases with an early return, and which the agent loop already tracks separately astext_only_transport(_build_route_request_state,_tool_schemas_for_routestrips MCP schemas to[]for it).GET /api/session/{sid}/tool-supportandtool_support_noticeboth key off exactly this, not off the api/native/ compat triad —"supported": falseisfaustus-cli://only,"supported": truecovers both native and fenced tool-calling models.- No fallback, ever. Neither
tool_support_noticenor the/tool-supportroute reads or writesSession.model/Session.endpoint_url; both are pure reads.stamp_connector...(viasave_assistant_responseinroutes/chat_helpers.py) only ever addsmetadata["notice"]to the saved assistant message — it cannot change what actually ran the turn. The wording of the stamped notice was deliberately checked (seetest_tool_support_notice_never_mentions_switching_model_or_endpoint) to never imply a substitution happened. - Fail-open on an internal connector-policy error, not fail-closed.
Both enforcement points wrap their
resolve_allowed_servers_for_sessioncall in atry/exceptthat logs and falls through to "not blocked by connector policy" on failure — consistent with every other best-effort lookup already in this codebase (_load_mcp_disabled_map,get_session_behavior_mode,project_for_session, ...), all of which degrade to "this feature contributed nothing" rather than breaking the turn. A connector restriction is additive user intent, not a security boundary imposed on the user by someone else — degrading it to "unrestricted" (today's default for every existing session/task) on an unexpected internal error is the same posture as every other optional policy layer here, not a new risk.
src/connectors.py,src/connector_sidecar.py,routes/connector_routes.py(F1's catalog/presets/sidecar/health) — not created here, not imported by anything insrc/connector_policy.py. F2's policy is defined purely in terms ofMcpServer.id, which existed before F1 or F2.- The Studio
/connectorsscreen,ConnectorPicker, and the adapters that will call the routes documented above — F3.