π Bug Summary
Four related bugs found in the prompts service layer (mcpgateway/services/prompt_service.py), discovered during investigation of #2420.
Bug 1 β AttributeError crash when visibility=None
register_prompt and update_prompt call visibility.lower() unconditionally. The REST API endpoint declares visibility: Optional[str] = Body("public", ...), so a client sending {"visibility": null} causes AttributeError: 'NoneType' object has no attribute 'lower'.
Bug 2 β Missing private conflict check in register_prompt
register_prompt only guards against duplicate public and team prompts. The private conflict check that exists in update_prompt is absent, allowing two private prompts with the same name and owner to be silently created.
Bug 3 β Stale argument_schema after template-only update
When update_prompt receives a new template but no arguments, the argument_schema.required list is never recomputed. The stored schema stays tied to the old template's variables, causing spurious validation errors for new template placeholders until arguments is also explicitly re-sent.
Bug 4 β _get_required_arguments adds false required args via Formatter.parse()
In addition to Jinja2's meta.find_undeclared_variables, the method also runs Python's string.Formatter().parse() over the raw template source. Any single-brace {...} in JSON content, dict literals, or Jinja2 {% set x = {"a": 1} %} expressions is spuriously added as a required argument.
π§© Affected Component
Select the area of the project impacted:
π Steps to Reproduce
Bug 1 β AttributeError on None visibility:
POST /prompts with body {"name": "test", "template": "hello", "visibility": null}.
- Observe
500 Internal Server Error with AttributeError: 'NoneType' object has no attribute 'lower'.
Bug 2 β Duplicate private prompt:
POST /prompts with {"visibility": "private", "name": "my-prompt", "template": "hello"} as user alice.
- Repeat the same request.
- Observe: both succeed. No
409 Conflict is raised, unlike public and team prompts.
Bug 3 β Stale argument schema:
POST /prompts with {"template": "Hello {{ name }}"} β argument_schema.required is ["name"].
PATCH /prompts/{id} with only {"template": "Hello {{ first }} {{ last }}"}.
POST /prompts/{id} with body {"first": "A", "last": "B"} (MCP spec prompt retrieval).
- Observe: validation error claiming
name is missing, because argument_schema.required was never updated.
Bug 4 β False required args:
- Register a prompt whose template contains a JSON literal, e.g.:
Here is the config: {"key": "value"}
- Observe:
argument_schema.required includes "key" even though it is not a Jinja2 variable.
π€ Expected Behavior
visibility=None should default to "public" (the declared parameter default) without crashing.
- Registering a duplicate private prompt for the same owner should raise
PromptNameConflictError (HTTP 409), consistent with update_prompt and with the existing public/team checks in register_prompt.
- Updating only
template should atomically recompute argument_schema.required to reflect the new template variables.
_get_required_arguments should return only genuine Jinja2 undeclared variables as identified by jinja2.meta.find_undeclared_variables, without adding Python str.format-style tokens from literal content.
π Logs / Error Output
Bug 1:
AttributeError: 'NoneType' object has no attribute 'lower'
File "mcpgateway/services/prompt_service.py", line 844, in register_prompt
if visibility.lower() == "public":
Bug 3 β example misleading 422:
{"detail": "Missing required argument: name"}
returned when {"first": "Alice", "last": "Smith"} is supplied after a template-only update.
π§ Environment Info
| Key |
Value |
| Version or commit |
main (identified during review of #2420) |
| Runtime |
Python 3.11, FastAPI / Gunicorn |
| Platform / OS |
Any |
| Container |
Any |
π§© Additional Context
- All four bugs are confined to
mcpgateway/services/prompt_service.py.
- Bug 1:
register_prompt and update_prompt call visibility.lower() unconditionally β callers can pass None explicitly via the REST API.
- Bug 2:
register_prompt has public and team duplicate-name guards but no private one; update_prompt does have a private guard β an asymmetry introduced at some point between the two methods.
- Bug 3:
argument_schema is only recomputed inside if prompt_update.arguments is not None:; a template-only PATCH skips that block entirely.
- Bug 4:
from string import Formatter is imported solely for _get_required_arguments; jinja2.meta.find_undeclared_variables already covers all Jinja2 variables correctly.
π Bug Summary
Four related bugs found in the prompts service layer (
mcpgateway/services/prompt_service.py), discovered during investigation of #2420.Bug 1 β
AttributeErrorcrash whenvisibility=Noneregister_promptandupdate_promptcallvisibility.lower()unconditionally. The REST API endpoint declaresvisibility: Optional[str] = Body("public", ...), so a client sending{"visibility": null}causesAttributeError: 'NoneType' object has no attribute 'lower'.Bug 2 β Missing
privateconflict check inregister_promptregister_promptonly guards against duplicatepublicandteamprompts. Theprivateconflict check that exists inupdate_promptis absent, allowing two private prompts with the same name and owner to be silently created.Bug 3 β Stale
argument_schemaafter template-only updateWhen
update_promptreceives a newtemplatebut noarguments, theargument_schema.requiredlist is never recomputed. The stored schema stays tied to the old template's variables, causing spurious validation errors for new template placeholders untilargumentsis also explicitly re-sent.Bug 4 β
_get_required_argumentsadds false required args viaFormatter.parse()In addition to Jinja2's
meta.find_undeclared_variables, the method also runs Python'sstring.Formatter().parse()over the raw template source. Any single-brace{...}in JSON content, dict literals, or Jinja2{% set x = {"a": 1} %}expressions is spuriously added as a required argument.π§© Affected Component
Select the area of the project impacted:
mcpgateway- APImcpgateway- UI (admin panel)mcpgateway.wrapper- stdio wrapperπ Steps to Reproduce
Bug 1 β
AttributeErroronNonevisibility:POST /promptswith body{"name": "test", "template": "hello", "visibility": null}.500 Internal Server ErrorwithAttributeError: 'NoneType' object has no attribute 'lower'.Bug 2 β Duplicate private prompt:
POST /promptswith{"visibility": "private", "name": "my-prompt", "template": "hello"}as useralice.409 Conflictis raised, unlikepublicandteamprompts.Bug 3 β Stale argument schema:
POST /promptswith{"template": "Hello {{ name }}"}βargument_schema.requiredis["name"].PATCH /prompts/{id}with only{"template": "Hello {{ first }} {{ last }}"}.POST /prompts/{id}with body{"first": "A", "last": "B"}(MCP spec prompt retrieval).nameis missing, becauseargument_schema.requiredwas never updated.Bug 4 β False required args:
argument_schema.requiredincludes"key"even though it is not a Jinja2 variable.π€ Expected Behavior
visibility=Noneshould default to"public"(the declared parameter default) without crashing.PromptNameConflictError(HTTP 409), consistent withupdate_promptand with the existingpublic/teamchecks inregister_prompt.templateshould atomically recomputeargument_schema.requiredto reflect the new template variables._get_required_argumentsshould return only genuine Jinja2 undeclared variables as identified byjinja2.meta.find_undeclared_variables, without adding Pythonstr.format-style tokens from literal content.π Logs / Error Output
Bug 1:
Bug 3 β example misleading 422:
{"detail": "Missing required argument: name"}returned when
{"first": "Alice", "last": "Smith"}is supplied after a template-only update.π§ Environment Info
main(identified during review of #2420)π§© Additional Context
mcpgateway/services/prompt_service.py.register_promptandupdate_promptcallvisibility.lower()unconditionally β callers can passNoneexplicitly via the REST API.register_prompthaspublicandteamduplicate-name guards but noprivateone;update_promptdoes have aprivateguard β an asymmetry introduced at some point between the two methods.argument_schemais only recomputed insideif prompt_update.arguments is not None:; a template-onlyPATCHskips that block entirely.from string import Formatteris imported solely for_get_required_arguments;jinja2.meta.find_undeclared_variablesalready covers all Jinja2 variables correctly.