Skip to content

fix(mcp): pass the retain strategy to the worker - #3296

Open
fhiltscher wants to merge 2 commits into
vectorize-io:mainfrom
fhiltscher:fix/mcp-retain-pass-strategy
Open

fix(mcp): pass the retain strategy to the worker#3296
fhiltscher wants to merge 2 commits into
vectorize-io:mainfrom
fhiltscher:fix/mcp-retain-pass-strategy

Conversation

@fhiltscher

Copy link
Copy Markdown

Summary

The MCP retain tool accepts a strategy but never delivers it to the worker, so every strategy override is silently ignored and the bank-level configuration is used instead.

build_content_dict puts the strategy into the content item, and the call passes only the contents:

content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy, update_mode)
...
result = await memory.submit_async_retain(
    bank_id=target_bank,
    contents=[content_dict],
    request_context=request_context,
)

submit_async_retain writes the strategy into the task payload from its own keyword argument only:

if strategy:
    task_payload["strategy"] = strategy

Nothing reads a strategy back out of the content items, so the payload has none, apply_strategy() never runs, and retain_mission, entity_labels, retain_chunk_size and entities_allow_free_form all fall back to the bank config. No error, no warning.

Both async retain registrations are affected. sync_retain, a few lines below, already does it correctly — this PR applies the same idiom:

strategy=content_dict.pop("strategy", None),

api_retain (REST) is unaffected: it groups items by item.strategy and forwards it.

Impact

Measured on a bank whose canonical_document strategy restricts record_type to state, rule, procedure via per-strategy entity_labels, importing the same three documents twice:

Import path Facts Types outside the strategy schema
MCP 123 4 (3 analysis, 1 decision)
REST, identical config 61 0

The illegal values are only possible because the bank-level vocabulary was in force — with the strategy's entity_labels applied, build_labels_model() turns them into a Literal[...] that structured output cannot violate.

Worth noting for triage: the dry-run extract endpoint cannot reveal this, because it strips the extracted labels from its response. The defect is only visible in the stored facts, where it looks like model non-compliance rather than lost configuration.

Test

test_retain_passes_strategy_to_the_worker asserts that submit_async_retain receives strategy as a keyword. It fails without the change; the existing TestRetainNewParams cases keep passing. tests/test_mcp_tools.py: 201 passed. ./scripts/hooks/lint.sh passes.

The async retain tool built the strategy into the content dict and then
called submit_async_retain without it. That method only writes a strategy
into the task payload when it is passed as the keyword argument, so the
worker never saw one: apply_strategy() did not run and every strategy
override — mission, entity labels, chunk size, free-form setting — fell
back to the bank configuration, silently.

Pass it the way sync_retain right below already does. The REST endpoint
is unaffected; api_retain groups items by strategy and forwards it.
@fhiltscher

Copy link
Copy Markdown
Author

Ping on this one: it's a one-line fix for the MCP retain path dropping the strategy before it reaches the worker.

CI never ran on the PR — I assume it needs workflow approval for a first-time contributor. No rush.

@ebarkhordar ebarkhordar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call-site enumeration holds. Parsing every submit_async_retain call in the repo gives four production callers, and api/http.py plus engine/memory_engine.py already pass strategy, so these two registrations were the last gaps.

One coverage gap on the new test. _make_mcp_server defaults to include_bank_id=True (tests/test_mcp_tools.py:1026) while MCPToolsConfig.include_bank_id_param defaults to False (mcp_tools.py:94), so test_retain_passes_strategy_to_the_worker pins the bank_id registration and leaves the else branch, the one a default config registers, unpinned.

Measured at ca4ad99 in a clean python:3.12-slim container (pip install -e ".[test]"):

mutation -k strategy whole file
none 1 passed 201 passed
drop the strategy= line in the bank_id branch 1 failed
drop the strategy= line in the else branch 1 passed 201 passed

So half the fix can be reverted and the 201 tests stay green. That first row is also the signal your CI has not been able to give you yet: the file is green at your head. TestSyncRetain one class down already parameterizes both registrations (include_bank_id=True at :1459, False at :1466), so the same shape closes it:

async def test_retain_passes_strategy_without_bank_id_param(self, mock_memory):
    mcp = _make_mcp_server(mock_memory, {"retain"}, include_bank_id=False)
    await _tools(mcp)["retain"].fn(content="test", strategy="documents")
    assert mock_memory.submit_async_retain.call_args.kwargs["strategy"] == "documents"

strategy occurs exactly once in that test file, in your new test, so sync_retain's two registrations carry the same idiom with nothing pinning it either. That predates this PR and is yours to take or leave.

@fhiltscher

Copy link
Copy Markdown
Author

Thanks for the mutation run.

Added test_retain_passes_strategy_single_bank, and made include_bank_id
explicit on the existing test.

Re-ran your mutations against both retain registrations:

mutation result
none 202 passed
drop strategy= in bank_id branch test_retain_passes_strategy_to_the_worker fails
drop strategy= in else branch test_retain_passes_strategy_single_bank fails

tests/test_mcp_tools.py: 202 passed. ./scripts/hooks/lint.sh passes.

sync_retain left out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants