Recap
Let's be real: in the last blog we were running a local MCP server on my pc, wiring it up to GitHub Copilot like mad scientists. It was awesome. But it was a lab experiment. The "brain" was tethered to my machine.
So... what if we built the brain into the app itself?
The System
So here's the setup. We have three players in this game:
The Web App: React, Vite, Redux Toolkit. The chat lives at /magic-bookkeeping.
The Backend: NestJS, PostgreSQL, Redis. This is where the real work happens.
The AI Service: Claude Haiku 3.5 via Requesty.AI, sitting in between.
User be like: Yo what items we got?
Agent: Calls the itemAll tool to check the item inventory and then cool pause RENDERS a Full UI Component with item details! Not texts!
Also works for like Bengali and Voice inputs by the way
The frontend Contract:
This is what's keeping them connected.
The MCP Layer: But Not Actually MCP?
Plot twist: we call it MCP, but it's not really the Model Context Protocol. There's no stdio. No SSE. No @modelcontextprotocol/sdk. No JSON-RPC. We basically built our own tool-calling façade that looks like OpenAI's function spec so Claude knows what to do with it.
Since this was already a heavy enough ERP system with a lot of code that was written before the AI era,adapting it to a separate mcp server wouldn't be that easy, plus it would also mean we would have to maintain another server.
The Controller (only two endpoints!):
when a user first requests any info list_tool will list all available tools for the ai model
call_tool is where the magic happens, we pass in a system prompt for the ai service so that it knows how to select the tools for any specific operations or which tools to call for let's say getting items or adding new item to inventory.
the tool dto:
why "any" ?
Because every tool has a different structure.
arguments: any is probably the most honest TypeScript type ever written
And here is how the tools are dispatched:
tools.json is read from disk on every request, not cached. The switch pattern means adding a tool requires editing both the JSON file and this service. (cut me some slack , it was my first time building stuff like this :))
Anatomy of a mcp tool
there's two types of tools for this project
- Data Tools
- Component Tools data tools handles the actual service calls and response payloads while component tool is just used used for generative UI which we'll get to later.
here's a data tool declared inside tools.json:
this is how we handle the tool via our mcp service:
Result is the raw entity returned by the domain service.
Now compare it to the component tool
a create item tool in tools.json:
handler in mcp.service.ts:

Instead of touching the database the backend returns a "render this component" envelope. The frontend's registry maps AiItemForm to the actual React component.
The Cool Part: Generative UI
Okay so the AI can fetch data. Great. But here's where it gets spicy. What if instead of returning boring JSON, the backend says: "Hey frontend, render this form"?
But we can't just ask it to use html templates , no? The forms need to dynamic, the same form the user uses to enter item details or add a new entry to the inventory should also be rendered here basically. But how did I achieve this?
Quite simply I wrap those already existing form components as separate render-able components , put them in a component registry and then it's just a matter of prompting the model correctly so it renders the right form based on user intent.

The response type that makes this possible:
When render_type === 'component', the frontend looks up a component by name in a static registry and renders it inline in the chat. No LLM-generated markup, no code-eval; the model only selects which component and fills its props from the Tool Registry:

Adding a new generative component requires editing this file. There is no runtime discovery ,the server can only refer to components the frontend has already imported.
Finally it all comes together inside the Chat!
If component_name isn't in the registry, the branch silently falls through to , no error, no fallback UI.Callbacks are hard-wired for AiItemForm only.(Cause I'm lazy)
The form-vs-list distinction is a string-includes check on the name.
And lastly here's how a user's message would flow through the system and repond with generative components:
it's 5 am in my place now so that's it for now, you can checkout the other blogs in the series or don't I don't care, this was a company project so I couldn't share the full code :)
Sayonara people
aurthor's note: maybe I'll add in a video later on of the working project, it's obviously not something you could just put in prod but something that at least works, some parts need more work, but if you've Actually read this far! Thanks!













Top comments (0)