Read-only tools are half a server. A real tool server also lets an assistant change something and have that change persist.
1Tools Can — and Often Should — Have Side Effects
Not every useful tool is read-only. create_task and complete_task change TASKS, and that change is visible to every subsequent call, exactly like a real database would behave. This is what makes an MCP server useful for more than lookups — an assistant can actually get things done through it, not just retrieve information.
2Why In-Place Mutation Is Correct Here
TASKS holds the actual dict objects, not copies. When complete_task_handler finds the matching task and sets t["done"] = True, it's modifying the exact object already sitting inside TASKS — there's nothing else to update. Building a new dict and returning it, without also replacing the one inside TASKS, would silently leave the stored task unchanged.
3Step-by-Step Breakdown
A Tool That Actually Changes State. read_file and list_files only ever read. create_task and complete_task are different — they mutate real, shared state that later calls can observe. This is where a tool server starts to feel like a real backend, not just a read-only lookup.
Complete a Real Task. create_task_handler and list_tasks_handler are done. complete_task_handler finds the right task by id but never actually marks it done — finish it so that task becomes done in place.
Why does complete_task_handler mutate the task dict in place (t["done"] = True) rather than building and returning a brand-new dict?
- →Because that same dict object is the one already stored inside TASKS — mutating it in place means every future call to list_tasks_handler sees the update, since there's only one shared list of tasks.
- →Because building a new dict is syntactically illegal inside a for loop in Python.
Module 2 Complete: A Real Server Core. DevAssist can now really read files, list them, create tasks, and complete them — all through a working dispatch table. It's still only reachable from Python code calling it directly, though. Next module: connecting a real LLM that decides on its own which of these tools to call.
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Confirm State-Changing Tool Calls in Their Result
A create_task or complete_task result should echo back the task's new state (like its id and done status) so any client rendering it can immediately confirm to the user what actually changed.
return {"id": 1, "done": True}SEO Implications
- 1
Target 'MCP tool with side effects' and 'stateful MCP server example' separately
Developers moving past read-only tools search for the state-mutation pattern specifically, distinct from basic tool setup.
Best Practices
Return the Updated Object From Any State-Changing Tool
Returning the task after completing it lets the calling model (and any UI) confirm the change succeeded without a separate follow-up list_tasks call.
Frequent Bugs
Reassigning a local variable instead of mutating the stored dict, e.g. `t = {**t, "done": True}`.
That creates a brand-new dict bound only to the local variable `t` — the original object still sitting inside TASKS is untouched, so the change is silently lost.
Real-World Examples
Real Task-Tracker Integrations
A production MCP server backing a real task tracker performs the equivalent of this in-place update as a database UPDATE statement — the in-memory version here is the same mutation pattern at smaller scale.
UPDATE tasks SET done = true WHERE id = 1