← Back to Blog
8 min read

AoPS Internship Tech Stack Details

#MCP#Node.js#Next.js#Kubernetes

If you haven't already read my blog post about my learnings, do that first, it gives context on the project and my overall takeaway.

Technical process

For an overall tech stack overview, we created a Node.js backend hosting the MCP Server, the API endpoints and websockets for the frontend, and the temporary AI chat data stored in memory. We also created a Next.js frontend with an OpenAI Chat interface, a code view to see the JSON created, and a chart view to see the structure of the lesson on a larger scale.

Node.js Server

We created a Node.js server using TypeScript and the official MCP TypeScript SDK. We set it up with Streamable HTTP, setting up the endpoints according to the MCP Server Protocol. We opted to skip using Express.js for more fine-tuned control to the protocol in case we needed it. It was also a learning opportunity to understand the MCP Protocol in finer detail.

We created 15 tools (I think, I kinda forgot the exact number), each matching a specific data structure within the AoPS Platform. So, the LLM would see a Multiple Choice Question within the Google Doc and would know to call the create-multiple-choice tool within the MCP Server.

The old JSON data schema was stored in a legacy repo, not using any modern libraries. So, we had to recreate the entire schema within TS with zod. We wanted to use TypeBox because that was the standard within AoPS, but unfortunately the Official MCP TypeScript SDK doesn't take in base JSON Schema, it takes in zod objects as inputs. This annoying shortcoming left us with no choice but to use zod instead of TypeBox. Zod did end up being pretty nice to use though.

zod has a function called z.toJSONSchema() which converts the zod object to standardized JSON Schema. TypeBox stores them as standardized JSON schema, not as some proprietary object. So, while MCP requires JSON schema, the TS SDK takes as input zod objects then converts to JSON schema behind the scenes.

Deployment

We dockerized this server and hosted with Kubernetes. We created our own namespace within a test cluster where we had free reign to adjust how we wanted. We created the namespace file, deployment file, service file, and ingress file.

At this stage we didn't have a frontend--just the MCP Server that we could connect to with Claude from a local machine on the AoPS IP address. So, instead of doing any complex user authentication, it was easier at this stage to just ip-whitelist the internal IP address so that nobody outside of that IP can access this server.

Next.js frontend

We managed to finish the MCP Server functionality about halfway through the internship. So, with the extra time, we decided to create a frontend interface to make the workflow centralized to one site. Also made for a learning opportunity to gain some more experience with Next.js.

API Endpoints

In order to create the frontend interface, we had to link the backend data somehow. So, we created API endpoints only accessible through pod-to-pod internal routing. This meant the API requests had to come from server-side components, not from client-rendered components. This was my first introduction to this functionality. I set up server actions to make the API requests from the Next.js server rather than the browser. This prefilled the browser with the existing data from the MCP Server. However, it didn't update in real time yet.

Websockets

We decided to implement websockets on the Node.js server. We used ws for the websocket server hosting and, based on the fact that the platform isn't going to have vast amounts of traffic, opted out of user authentication and websocket messages specific to a client. We made it so that the updates are sent to specific sessions only though. So, when the user accesses the site, when they activate a new MCP session, they'll receive websocket updates from that session only. They won't receive updates for other MCP sessions.

Since we did no user auth though, any user can see all sessions if they want. While simple, this was also a feature we wanted so that all users can watch the lessons be created in real time.

For Websockets, since we only had internal routing, we again had to send this info to the Next.js server, not the browser client. So, we set up Server Sent Events (SSE) where the server receives all requests, and the server sends events to the client based on the client's "subscribed sessions".

AI Chatbot Interface

To make the frontend as seamless as possible, we wanted to include an AI Chatbot interface within the website to remove the need for users to set up the Claude Desktop app and install node locally. So, we used the OpenAI API because the company had previously used this.

JWT Authentication

We ran into a challenge though: the OpenAI API connects to the MCP server from an external IP. This meant we had to open the MCP Server to external IPs as well. To overcome this, I set up JWT auth. Here's how the auth worked:

  1. Frontend makes API request to Node.js backend requesting a signed bearer token for 30 minutes. Stores this in React context.
  2. Frontend makes OpenAI API request with MCP Server details and a signed bearer token in the Authorization header.
  3. OpenAI servers connect to MCP Server with JWT bearer token, get authorized and make the connection.

However, to keep MCP Server access with Claude too, we split these into different endpoints. If you access the /mcp endpoint, you need to be on an AoPS IP address. With the /mcp/external endpoint, you need to have a valid bearer token to connect. Since we opted to use Node.js at the start instead of Express.js to handle the routing, this was made much easier. We had finer control over these little details.

In the kubernetes ingress, all we had to do was create a new ingress for the /mcp/external route that had no ip-whitelist attached to it with traefik.

Challenges

One very interesting challenge we ran into had to do with the OpenAI API connecting to our MCP server. For some reason, we could connect to our MCP server through Claude, indicating it was working properly, but we couldn't connect to it via the OpenAI API. It was failing silently. No specific error messages. Just a "couldn't connect". We confirmed that the JWT auth was working properly by using the MCP Inspector tool, so it had to do with something else.

I set up detailed logging on the MCP server side, logging every single API request made. So, when the OpenAI API tried to connect, I could see the initial POST initialize request and the following POST confirm request confirming that the API is connected. So, I managed to confirm that it was connected and received the tool list.

However, when trying to call a tool, there was a sneaky DELETE request right before the POST request, deleting the connection before it could call the tool. Not sure why this bug existed, and I found many other users in the OpenAI forums dealing with the same issue, but with no workaround. Luckily, I managed to find a loophole in the MCP Server Protocol that allows me to ignore DELETE requests as long as the server automatically handles connection cleanup.

So, we implemented server connection cleanup, ignore the DELETE request, and it now works as intended.

The decision at the beginning to go with custom protocol handling was a good decision in the end because using Express.js wouldn't have allowed me to make that fix. Express.js was supposed to handle the connections "automatically", but other Express.js users were running into the same issue with no workaround.

Result

The platform we created allows the curriculum team to import outline docs into our site, generate the JSON for the AoPS platform within 5 minutes, then verify that the lesson created was correct. It overall reduced the time for lesson importing from about 20 hours to about 2. I'm really glad to have made an impact on the teams that create the lessons that made me who I am today.

I gained a lot of technical experience using Next.js, MCP, Node.js, Kubernetes, Docker, docker-compose, JWT, websockets, RESTful APIs, and more. I'm super grateful to have had the opportunity to work at AoPS.