feat(mcp): register and execute custom tool profiles - #677
Draft
vishal-bala wants to merge 1 commit into
Draft
Conversation
Completes the stack: the machine that honors the profile config models, and the point at which a `custom_tools:` entry becomes a real tool. `register_profile_tool` builds each profile's wrapper signature dynamically and hands it to FastMCP, which derives the advertised input schema from that signature and marks it `additionalProperties: false`. That is what makes a locked or hidden argument genuinely unreachable rather than merely undocumented -- the model cannot name an argument the schema does not contain. The wrapper still re-checks exposure per call rather than trusting the schema alone. The `filter` annotation is an object type, never a string, so a raw filter string is refused by the advertised schema; the wrapper refuses one too, because the schema is the client's contract and the wrapper is the server's. A `limit` cap is published as `Field(le=cap)` so the ceiling is visible to the model rather than only enforced on rejection. Startup validation catches what config load could not, since it needs the inspected schema: a locked projection or filter naming a field the bound index does not have, a locked `exists` on a field without INDEXMISSING, or one on a vector field. It runs before registration so a bad profile fails startup instead of leaving a half-registered tool set. Two operational hazards get warnings rather than silence. Tools register once per process, but a profile bakes its locked filter, projection, and signature in at registration time -- so a restart that reloads a *changed* config would keep enforcing the old profiles. The dangerous direction is an operator tightening a lock and believing the restart applied it, so the server fingerprints the config its tools were built from and warns when that no longer matches. The empty-surface warning also now names `custom_tools` as a possible cause. Adds the integration coverage that exercises profiles against real Redis, the concept and how-to documentation, and unit tests for registration, execution, description building, and per-binding lock isolation. The restart-warning path and `_register_tools` idempotency are covered here too, since both only became load-bearing once profiles existed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack position: 3 of 3. Base is #676. This completes the feature — the point at which a
custom_tools:entry becomes a real tool.How locked arguments are actually unreachable
register_profile_toolbuilds each profile's wrapper signature dynamically and hands it to FastMCP, which derives the advertised input schema from that signature and marks itadditionalProperties: false. That is what makes a locked or hidden argument genuinely unreachable rather than merely undocumented — the model cannot name an argument the schema does not contain.I verified empirically that FastMCP derives its schema from a dynamic
__signature__, since the whole design rests on it. The wrapper still re-checks exposure per call rather than trusting the schema alone.The
filterannotation is an object type, never a string, so a raw filter string is refused by the advertised schema; the wrapper refuses one too, because the schema is the client's contract and the wrapper is the server's. Alimitcap is published asField(le=cap)so the ceiling is visible to the model rather than only enforced on rejection.Startup validation
Catches what config load could not, because it needs the inspected schema: a locked projection or filter naming a field the bound index does not have, a locked
existson a field withoutINDEXMISSING, or one on a vector field. It runs before registration so a bad profile fails startup instead of leaving a half-registered tool set behind.Two operational hazards that would otherwise be silent
Tools register once per process, but a profile bakes its locked filter, projection, and signature in at registration time. A restart that reloads a changed config would therefore keep enforcing the old profiles. The dangerous direction is an operator tightening a lock and believing the restart applied it, so the server fingerprints the config its tools were built from and warns when that no longer matches.
The empty-surface warning from #668 now also names
custom_toolsas a possible cause.Also included
Integration coverage against real Redis, the concept and how-to documentation, and unit tests for registration, execution, description building, and per-binding lock isolation.
Verification
make check-types: cleanAfter this merges
The integration branch holds #675 + #676 + this, and squash-merges to
mainas one "custom tool profiles" commit. Phase 2 (auth-claim tenant injection) and v1.1 (code tools) are separate follow-ups and not in this stack.One thing recorded for phase 2:
TokenEscaperdoes not escape|, so a scalar claim likeacme|evilwould render@tenant_id:{acme|evil}— a cross-tenant OR. Harmless here because the value is ANDed under the lock, but claim injection must validate claim characters, not just type.