How to Build Your Own MCP Server and Publish Your ChatGPT App with Supabase Auth and DigitalOcean

A new type of app is emerging with the development of LLMs and AI-native apps. It lives inside an AI chat (like ChatGPT) rather than being a fully native web or mobile app.

In this tutorial, you’ll learn how to build an MCP (Model Context Protocol) server from scratch, including a UI you can use as a ChatGPT app with authentication and a database.

You’ll go through the process of building, testing, adding the ChatGPT app as a connector, and submitting it to publish to the app directory. This will let you build the app on three levels:

  • Level one: you will build your basic MCP Server that returns textual data.

  • Level two: you will build a UI for your MCP Server to be used within an LLM UI.

  • Level three: you will add authentication and a database to your MCP Server.

To fully understand this article, you’ll need to have basic knowledge of:

  • Web development

  • JavaScript

  • React and React Native

  • SQL and databases

What We’ll Cover:

What is an MCP Server?

A Model Context Protocol (MCP) server is a program that exposes tools, resources, and prompts to an AI application through a standard protocol. An MCP server can provide read-only context, callable tools, or reusable prompt templates that help extend what an AI application can do.

A developer builds or configures the MCP server, and an MCP client inside a host application connects to it. The application can then allow the model to discover available capabilities and, when appropriate, invoke tools or fetch resources via the MCP protocol to help complete a task.

What Can You Do with an MCP Server?

An MCP server lets an AI application work with information and systems outside the model itself. For example, it can help the model look up current information, save and retrieve user data, search documents, or trigger actions in another application.

In practice, one MCP server might connect to an online database, while another might work with files on your local machine. This makes it possible to build AI workflows that are more useful, practical, and connected to real tools.

Level 1: How to Build Your Own MCP Server

In this tutorial, you’ll learn how to build an MCP server using the default HTTP server from Node.js, Supabase for the database and authentication, and the official MCP server SDK. Then you’ll deploy it to DigitalOcean and publish your app on ChatGPT.

That means you’ll do two steps here:

  • First step: connect your deployed MCP server to ChatGPT as an app/connector so it can be used within ChatGPT.

  • Second step: submit the app for review and, if approved, publish it to the ChatGPT app directory.

The MCP server SDK isn’t the only tool or framework for building your own MCP server. You can use other SDKs and tools for that if you prefer. But to simplify the first steps, here I’ve decided to use the more straightforward tools.

Step 0: Prepare your project

You’re going to write a full project here, so you should start by creating packages and initializing the project. To do this, follow these steps:

  • Create a new folder with the project name. For this example, you can use mcp_todo.

  • Navigate to this new folder.

  • Open the terminal in this folder.

  • Initialize the npm project with npm init --init-type=module -y to create a JavaScript package file and add the packages to the project with ES6 support.

  • Initialize Git with git init in the project to enable version control and track changes.

  • Install related packages that you’re going to use in your project:

    • The packages are Supabase, the MCP SDK (which we’ll cover in step 2), and the zod validation package for validating LLM inputs and data.

      npm install @modelcontextprotocol/sdk zod @supabase/supabase-js
      
  • Create a .gitignore file and add the node_modules to it so that it won’t be tracked by Git.

  • Add the current state of the project to your Git tracker by writing the following:

With this, you’ve created a new project for yourself that you can use as a starting point for managing and following the project.

Step 1: Create a Node.js Server

To start the project, you’ll need to create a simple Node.js server, which you can do by creating a new file named server.js and writing the following code:

import { createServer } from "node:http";

const port = Number(process.env.PORT ?? 8787);

const httpServer = createServer(async (req, res) => {

    console.log(`${req.method} ${req.url}`);

    if (!req.url) {

        res.writeHead(400).end("Missing URL");

        return;

    }

    const url = new URL(req.url, ` ?? "localhost"}`);

    res.writeHead(404).end("Not Found");

});

httpServer.listen(port, () => {
    console.log(`Todo MCP server listening on  press Ctrl+C to stop`);
});

This is a simple Node server that you’ll use as the base for building your MCP Server.

To build your MCP server, you’ll need to set it up using the MCP Server SDK. After that, you’ll need to define two things: the tools you’ll show the LLM and the UI and resources the LLM will use to render.

To define the tools and UI concepts, you’ll use the MCP Server SDK.

Step 2: Setting Up MCP Server SDK

To set up and start the MCP server, you need to have the following:

  • Tools: The functions exposed by MCP Server to an LLM, enabling the LLM to interact with the server and external systems. Like calling an API, performing a computation, or querying a database.

  • Resources (optional): Data the MCP Server shares with an LLM. For example, a file, database schema, or an HTML UI to use inside the LLM Chat UI as an embedded frame.

You can start the server by adding this line of code at the top of the server.js file:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

function createTodoServer() {
    const server = new McpServer({ name: "todo-app", version: "0.1.0" });
    return server;
}

Then add a tool and resources using the following function signature:

server.registerTool(
    "NAME",
    {},
    async (args, meta) => { }
);

You can think about the tool registrar as your endpoint to the MCP Server. The LLM will check it and, based on the name and metadata, start processing the data using the arguments and results you have in this tool.

Today, you’re going to build three simple tools:

  • Add todo

  • Update todo

  • List todos

They all look a bit similar, but you’ll see how to write them all to understand the concepts in the next sections.

Step 3: Add MCP Server Tools – Create and Add a Todo

To start with, when adding todos, you’ll need a simple in-memory array to manipulate. You can create the array outside the create server function to access it throughout the server.

let todos = [];// outside the createTodoServer function block
let nextId = 1; // outside the createTodoServer function block (this is a mock id for your todos)

After the array, you’ll need to have two more supporting functions: first, the validator for the tools, which specifies the expected input types from the LLM.

At the top of the file, you should import the zod library:

import { z } from "zod";

Then you can write the helper function to validate it and tell the LLM what to expect from them:

const addTodoInputSchema = {
    title: z.string().min(1),
}; // outside the createTodoServer function block

Next, you’ll need the return function, which you can use with other functions to have a unified return function for the tools

const replyWithTodos = (message) => ({
    content: message ? [{ type: 'text', text: message }] : [],
    structuredContent: { tasks: todos },
}); //outside the createTodoServer function block

Then you can register the add todo function in the server, inside the createTodoServer function block, before return server:

server.registerTool(
    'add_todo',
    {
        title: 'Add todo',
        description: 'Creates a todo item with the given title.',
        inputSchema: addTodoInputSchema,
        _meta: {
            'openai/toolInvocation/invoking': 'Adding todo',
            'openai/toolInvocation/invoked': 'Added todo',
        },
    },
    async (args) => {
        const title = args?.title?.trim?.() ?? '';
        if (!title) return replyWithTodos('Missing title.');
        const todo = { id: `todo-${nextId++}`, title, completed: false };
        todos = [...todos, todo];
        return replyWithTodos(`${todo.title}`);
    },
); // inside the createTodoServer function block

In the above code, you’ve added the tool name and used a simple approach to add the todos to the in-memory array you already identified. The trick here is to validate the data before adding it and create the related object for it.

In the metadata, you’ve added the title, description, inputSchema, and _meta for OpenAI to use while rendering this. You’ll get a rendering, add a todo when the AI adds it, and have the latest version of the added todo when it’s finished.

At the same time, you’ve added the input schema so the LLM knows what to provide when invoking your server, and you’ve added a reply helper function to handle your todos. It’s a simple function that shows the todos in a structured way for LLMs to understand.

Step 4: List Todos from MCP Server

To list the todos, you can use a simple list function to show the todos without any changes. In the code below, you use the same concept for naming, metadata, and description context as you provided before. You’re also using the previous helper function to return the todos that you have in memory. You should write this code inside the createTodoServer function block.

server.registerTool(
  'list_todos',
  {
    title: 'List todos',
    description: 'Lists all todo items.',
    _meta: {
      'openai/toolInvocation/invoking': 'Listing todos',
      'openai/toolInvocation/invoked': 'Listed todos',
    },
  },
  async () => {
    return replyWithTodos();
  },
);

Step 5: Add Todo Complete Functions

To complete and edit todos, you can create a new tool with that name that takes the todo ID and returns the updated todos. To do this, you need to add the helper function for validating the request outside the createTodoServer:

const completeTodoInputSchema = {
    id: z.string().min(1),
};

Then inside the createTodoServer function, you can add the following:

server.registerTool(
    'complete_todo',

    {
        title: 'Complete todo',
        description: 'Marks a todo as done by id.',
        inputSchema: completeTodoInputSchema,
        _meta: {
            'openai/toolInvocation/invoking': 'Completing todo',
            'openai/toolInvocation/invoked': 'Completed todo',
        },
    },

    async (args) => {
        const id = args?.id;
        if (!id) return replyWithTodos('Missing todo id.');
        const todo = todos.find((task) => task.id === id);
        if (!todo) {
            return replyWithTodos(`Todo ${id} was not found.`);
        }
        todos = todos.map((task) =>
            task.id === id ? { ...task, completed: true } : task,
        );
        return replyWithTodos(`Completed "${todo.title}".`);
    },
);

In this tool, you used the same function definition as for list todos, while adding extra guards to check whether the LLM has returned the ID and whether that ID is correct. You should always manually check the data you have before processing it, since LLMs can hallucinate and aren’t required to validate their inputs.

Now you can commit your code to Git and track it in the tree:

Step 6: Connect Your MCP Server with the Node.js Server

Since you have written the main functions for the MCP server, you need to connect your MCP server to the Node.js HTTP server.

To do that, you need to write the streamable function and the related code. You will use this code on top of the server code from step 1 as a replacement, since it includes more functions to handle the MCP server.

First, import the StreamableHTTPServerTransport function:

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

Then you can copy the next code and replace it with the server code, which has the server’s structure, to use in your project.

const port = Number(process.env.PORT ?? 8787);
const MCP_PATH = '/mcp';

const httpServer = createServer(async (req, res) => {
    if (!req.url) {
        res.writeHead(400).end('Missing URL');
        return;
    }

    const url = new URL(req.url, ` ?? 'localhost'}`);

    // handle the options call for the endpoint
    if (req.method === 'OPTIONS' && url.pathname === MCP_PATH) {
        res.writeHead(204, {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
            'Access-Control-Allow-Headers': 'content-type, mcp-session-id',
            'Access-Control-Expose-Headers': 'Mcp-Session-Id',
        });
        res.end();
        return;
    }

    // handles normal get method for the main link
    if (req.method === 'GET' && url.pathname === '/') {
        res.writeHead(200, { 'content-type': 'text/plain' }).end('Todo MCP server');
        return;
    }
    // here you are handling your MCP calls with streamable HTTP
    const MCP_METHODS = new Set(['POST', 'GET', 'DELETE']);
    if (url.pathname === MCP_PATH && req.method && MCP_METHODS.has(req.method)) {
        res.setHeader('Access-Control-Allow-Origin', '*');
        res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
        const server = createTodoServer();
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined, // stateless mode
            enableJsonResponse: true,
        });
        res.on('close', () => {
            transport.close();
            server.close();
        });
        try {
            await server.connect(transport);
            await transport.handleRequest(req, res);
        } catch (error) {
            console.error('Error handling MCP request:', error);
            if (!res.headersSent) {
                res.writeHead(500).end('Internal server error');
            }
        }
        return;
    }
    res.writeHead(404).end('Not Found');
});

httpServer.listen(port, () => {
    console.log(
        `Todo MCP server listening on 
    );
});

In this code, you’re running the main HTTP server to handle the requests. The server exposes a /mcp endpoint for MCP clients and connects each request to a stateless MCP server using Streamable HTTP.

Now you can commit your code to Git and track it in the tree:

How to Test Your MCP Server

Now you can test the basic structure of your MCP server by running the following code:

node server.js

By using this command, you’ll run the server you created in the previous steps. It will make it active and listen to changes at . After running server.js, you need to open the inspector, a tool that helps you see the MCP server registration and the endpoints and tools you need to use and run in a secure environment.

npx @modelcontextprotocol/inspector@latest --server-url  --transport http

When you run the previous command, you can see that you have a connection to your MCP, and you need to run it and use it through the inspector UI. Using the inspector UI will help you test your MCP server without connecting it to any external services and test the inputs and outputs locally.

Showing MCP Server Inspector Too

To test your tools, connect to the server first, and then you can see and explore them.

After writing this code, you may wonder: what UI could I show the user through an LLM? If you run your project right now, you’ll only get text results as LLM chat answers. But if you build a UI, you can improve your LLM’s experience. In the next section, that’s what we’ll tackle.

Level 2: How to Build the UI

With the previous code, you built a simple MCP server that adds todos to a todo list and marks them as complete from the app. Now you’re going to explore the registerResource tool, which registers a UI resource of your design so ChatGPT can use it.

Resources are the LLM-specific data provided by your MCP Server. You can share your UI with the LLM so it can use it to display additional data and widgets in the chat.

To share the UI, you need to have an HTML file that relies on your MCP server data and uses the MCP server. So for that, you’ll create a new HTML file.

Step 1: Create the HTML File to Show the UI

The TodoHTML you provided earlier should be an HTML file that can communicate with the Server and the ChatGPT UI. The UI will look like the following image:

UI Style Inside ChatGPT

To build such a UI you saw previously, you need to create a public/todo-widget.html file and write the following structured code:



  
    
    Todo list
    
  
  
    

Leave a Comment

Scroll to Top