I admittedly have some work to do to catch up with the AI “trend”. It’s been around (as in, easily accessible) for a few years now, but I can probably still count on my fingers the number of times I’ve used a prompt to ask it anything. That is, discounting the mostly frustrating and usually unrequested interactions with a service provider chatbot of some form…
How come most of them still do such a lousy job?
I was told part of the problem is more related to a lack of proper domain knowledge than with the current state of AI: if the bots are fed with bad documentation and incomplete or inaccurate data, they can’t possibly provide/fabricate good advice. Thus, the ambition is to give AI access to more than a pile of PDFs that were put together a few months ago.
The MCP protocol
Let me not pretend I know more about this beyond the basics, but my understanding is that this shared need gave birth to (or evolved into) the Model Context Protocol (MCP):
MCP is an open protocol that standardizes how applications provide context to LLMs. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools.
In short, while we cannot (for now, at least) simply give AI the coordinates to access our e-mail and database accounts and expect it to find its way to them, we can put it in contact with someone who can. Actually, not someone, but another service, a middleman of sorts, which can both access data (and services, too) that are not publicly available and communicate with AI using a common language.
I should note that the use of such intermediary agents is done by design: it allows us control over which portions of our data and services we want AI to have access to, as well as define in which contexts we want this to happen. Context is a catchy word in the AI world, but I take it as follows: it is not because we provide AI access to our e-mail accounts (considering we are brave enough to do that) that we want it to take into consideration the data it will find there for each and every question we ask it. Thus, we can be specific and selective: we may provide AI with access to that information only when we find it relevant.
MCP servers
If you haven’t heard this name before, you, too, have some catching up to do. That’s how those intermediary agents are called. You can find a collection of Model Context Protocol servers in the MCP GitHub repository. When I first looked at this page a couple of months ago, the list of reference servers was quite long. Now it is limited to half a dozen reference implementations, while most other servers that were once part of that list have been archived. I suppose the reason for that is simple: things are moving (changing) very fast in this area, in terms of development.
Thanks to the availability of standard frameworks, it became ridiculously easy to create a new MCP server from scratch. Despite that, there are a couple of requirements that you need to get right, or the integration with AI fails miserably (and with misleading stack traces that left me banging my head against the keyboard many times in the last few days). Highlighting and documenting those is what motivated me to write this post. Without further ado, here they are.
If you are creating an MCP server to connect with AI…
More often than not:
1) The MCP server needs to be accessible through a public URL
Maybe it’s just my inexperience, but I thought that, due to the fact that we use the AI provider’s own API locally, it could interact with a service that also sits locally. It cannot; the AI “brain” lives abroad.
This requirement is properly advertised in some places, less so in others.
2) AI can only connect to an MCP server over HTTPS
And self-signed certificates are not allowed (you can make it work with some hacking, though, depending on how you do it, at the expense of security).
If, like me, you’re only here for some testing, the simple solution suggested by FastMCP documentation is to use a third-party service like ngrok, which deals with both those requirements (more on this below).
Creating an MCP server with FastMCP
There are software development kits (SDKs) for creating MCP servers and clients in different languages, including Python, the only one I feel comfortable with. But my development skills are also limited: you will find better advice on how to properly write Python applications elsewhere. What I propose to show you here is how to make things work.
My test environment is a cloud VM running Ubuntu 24.04, where I’m hosting both the MCP server and a PostgreSQL test database server. As you will see soon, the only PostgreSQL-specific code used in this rather simple MCP server implementation is related to the Python module providing the database driver (psycopg2); to make it work with MySQL, I suppose we only need to replace psycopg2 with mysql.connector and adjust the respective functions used to open a connection, execute a query, and fetch its result. More sophisticated implementations can be made using other database-specific resources, such as the now-archived PostgreSQL reference MCP server.
The PostgreSQL MCP server
Here’s my simple procedure to get a PostgreSQL MCP server up and running:
- Something changed in the Python world in the last 10 years. There’s a trend to “jail” modules in a virtual environment, so we start by installing uv, which provides the said environment:
1 |
curl -LsSf https://astral.sh/uv/install.sh | sh |
- I created a virtual environment called e1 and activated it:
1 2 |
uv venv e1 source e1/bin/activate |
- Once inside the virtual environment, we can install the two Python modules we need using pip:
– FastMCP, a framework that implements the MCP protocol in Python, which in practice provides the MCP server for us;
– psycopg2, a Python driver for PostgreSQL:
1 |
uv pip install fastmcp psycopg2-binary |
- Here’s the PostgreSQL MCP server implementation, which I took from this Charming Data presentation, and saved in a file named mcp-pg-py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import json import psycopg2 from fastmcp import FastMCP mcp = FastMCP(name="PG demo") @mcp.tool() def query_data(sql_query: str) -> str: '''Execute PostgreSQL queries safely for the actors table inside the test database and return structured JSON response.''' DB_NAME = "test" DB_USER = "mcpreader" DB_PASS = "secret" DB_HOST = "localhost" DB_PORT = "5432" conn = None cursor = None try: conn = psycopg2.connect( dbname=DB_NAME, user=DB_USER, password=DB_PASS, host=DB_HOST, port=DB_PORT ) cursor = conn.cursor() cursor.execute(sql_query) rows = cursor.fetchall() finally: if cursor is not None: cursor.close() if conn is not None: conn.close() return json.dumps(rows, indent=2) if __name__ == "__main__": mcp.run() |
Note the database connection details above; I’ll talk about it in a moment.
- Finally, start the MCP server with:
1 |
fastmcp run -t sse -p 8000 mcp-pg.py |
You should see it running on localhost, port 8000, using the transport method Server Sent Events (SSE); most AI APIs can communicate over SSE or streamable HTTP, but not using the default Stdio method:
1 2 3 4 5 6 |
[07/04/25 15:39:59] INFO Starting MCP server 'PG demo' with transport 'sse' on server.py:1378 http://127.0.0.1:8000/sse/ INFO: Started server process [18564] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) |
A simple database to test with
My PostgreSQL test database is deliberately simple; one table with two rows:
- I did a standard PostgreSQL server install with:
1 |
sudo apt-get install postgresql-16 |
- Then connected to PostgreSQL:
1 |
sudo -u postgres psql |
- Create the test database and two-entry table:
1 2 3 4 |
CREATE DATABASE test; c test create table actors (id serial, name character varying(20), sex character(1)); insert into actors (name, sex) values ('John', 'M'), ('Mary', 'F'); |
- Finally, create the test user and grant it read access to my test table:
1 2 3 4 |
create user mcpreader with login encrypted password 'secret'; grant connect on database test to mcpreader; grant usage on schema public to mcpreader; grant select on public.actors to mcpreader; |
That’s it.
Does the MCP server work?
A simple curl command run locally should confirm the server is up and receiving requests:
1 2 3 |
$ curl http://127.0.0.1:8000/sse/ event: endpoint data: /messages/?session_id=e92f723a73b74c259dbfda7a57ebd43b |
But in order to test that it works, we need to try connecting to it. To do this, we can create a second uv virtual environment on the test server, install fastmcp, and start an ipython3 session:
1 2 3 4 |
uv venv e2 source e2/bin/activate uv pip install fastmcp ipython3 |
The first thing AI does when it connects to an MCP server is to request the list of tools it has available (we can say AI is still “learning” the MCP language, and, for now, it can only do tools). We can replicate this behavior by implementing a fastmcp client:
1 2 3 |
from fastmcp import Client async with Client("http://127.0.0.1:8000/sse/") as client: tools = await client.list_tools() |
If the request is successful, we should get something like:
1 2 |
In [5]: print(tools) [Tool(name='query_data', title=None, description='Execute PostgreSQL queries safely for the actors table inside the test database and return structured JSON response.', inputSchema={'properties': {'sql_query': {'title': 'Sql Query', 'type': 'string'}}, 'required': ['sql_query'], 'type': 'object'}, outputSchema={'properties': {'result': {'title': 'Result', 'type': 'string'}}, 'required': ['result'], 'title': '_WrappedResult', 'type': 'object', 'x-fastmcp-wrap-result': True}, annotations=None, meta=None)] |
That’s our tool, query_data, which requires a sql_query as input. Let’s give it a try:
1 2 |
async with Client("http://127.0.0.1:8000/sse/") as client: result = await client.call_tool("query_data", {"sql_query": "select * from actors"}) |
We can restrict the result content in different ways. The following should give us the essence of what we are looking for:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
In [7]: print(result.structured_content['result']) [ [ 1, "John", "M" ], [ 2, "Mary", "F" ] ] |
Yep, it definitely works! What good use can AI make of this “door” to our database?
HTTPS and SSL certificates
Let me hopefully spare you some frustrating time trying to connect AI to this MCP server; it won’t. In order for (most) AI APIs to be able to connect to an MCP server, it must be:
- accessible on the Internet
- over HTTPS
- using a valid certificate.
One way to achieve this is to run the Python code of the MCP server on a web server that can do HTTPS. Another one is to use the reverse proxy utility in Nginx. In both cases, you still need to configure valid SSL certificates or relax security constraints for those in FastMCP. In the end, I resigned myself to just doing what their documentation recommended for testing: I created an account on ngrok and then followed the simple instructions there to get an authentication token. Then, it took three steps to expose my MCP server to the Internet using their service:
1 2 3 |
snap install ngrok ngrok config add-authtoken <mytoken> ngrok http http://127.0.0.1:8000 |
It creates a public URL and forwards requests to the local server:
1 |
https://<redacted>.ngrok-free.app -> http://127.0.0.1:8000 |
It’s this endpoint that we should provide to the AI api to connect to our MCP server, with one important detail to add: the service is available under the /sse location. Thus, the target URL is actually https://<redacted>.ngrok-free.app/sse/.
AI, here’s how you can access my data
I have tried two AI products, Claude and ChatGPT. In order to access the respective APIs from Anthropic and OpenAI, you need to create an account and generate an API key. But, unless you have some credits with them (or know something I don’t), they won’t provide free service for API access:
1 |
RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.', 'type': 'insufficient_quota', 'param': None, 'code': 'insufficient_quota'}} |
I decided to give Anthropic a go instead and purchased some credits with them.
FastMCP has good documentation on how to integrate your MCP server with Anthropic’s API (as well as OpenAI’s and Gemini). I did more or less what they suggested there, while also checking Anthropic’s own documentation on MCP server integration.
From my laptop, which is also running Ubuntu, I started a uv virtual environment, installed the Anthropic Python module, configured my API key as an environment variable, and started an ipython3 session:
1 2 3 4 |
uv venv e1 source e1/bin/activate uv pip install anthropic export ANTHROPIC_API_KEY="<redacted>” |
Providing the API key as an environment variable avoids the need to provide it as an argument when creating the Anthropic client. We can start the chat now:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
from anthropic import Anthropic client = Anthropic() response = client.beta.messages.create( model="claude-opus-4-20250514", max_tokens=1024, messages=[ {"role": "user", "content": "How many female actors are there records for in the test database?"} ], mcp_servers=[ {"type": "url", "url": "https://<redacted>.ngrok-free.app/sse/", "name": "PG demo"} ], extra_headers={"anthropic-beta": "mcp-client-2025-04-04"} ) |
Chatting with Claude
In hindsight, that was a lousy example; I should have come up with a better test database idea and prompt. But Claude’s response provided an interesting perspective on how it both interpreted my request and worked around my imperfect English. Here’s a more human-readable excerpt from the response.content block:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
text="I'll query the database to count the number of female actors.", sql_query: "SELECT COUNT(*) as female_actor_count FROM actors WHERE gender = 'female'" text='Error calling tool 'query_data': column "gender" does not exist LINE 1: ... COUNT(*) as female_actor_count FROM actors WHERE gender = '...' text='Let me first check the structure of the actors table to see what columns are available:' sql_query': "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'actors' ORDER BY ordinal_position" text='[ [ "id", "integer" ], [ "name", "character varying" ], [ "sex", "character" ] ]' text="Now I can see the table has a 'sex' column instead of 'gender'. Let me query for female actors:" sql_query': "SELECT COUNT(*) as female_actor_count FROM actors WHERE sex = 'F'" text=' [ [ 1 ] ]' text='Based on the query results, there is **1 female actor** recorded in the test database actors table.' |
It’s interesting to see the “chain of thought” (if we are allowed to call it this way) that Claude employed to answer my question, including recovering from a failed assumption it made initially.
[ME]: Are there any indexes that could be added to the table actors to improve that query?
This time, I formatted AI’s response like this:
1 2 3 4 5 |
d = response.to_dict() c = d['content'] for i in c: if 'text' in i: print(f"[AI]: {i['text']}n") if 'input' in i and 'sql_query' in i['input']: print(f"[SQL]: {i['input']['sql_query']}n") |
Which resulted in the following output:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
[AI]: I need to first see the structure of the actors table and understand what indexes currently exist. Let me check that information. [SQL]: SELECT schemaname, tablename, indexname, indexdef FROM pg_indexes WHERE tablename = 'actors' ORDER BY indexname; [AI]: Let me also check the table structure to understand the columns: [SQL]: SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = 'actors' ORDER BY ordinal_position; [AI]: Now let me check if there's a primary key or any constraints: [SQL]: SELECT conname AS constraint_name, contype AS constraint_type, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conrelid = 'actors'::regclass; [AI]: Based on my investigation, I can see that: 1. The `actors` table has three columns: `id` (integer), `name` (varchar), and `sex` (char) 2. There are currently **no indexes** on the table (not even a primary key) 3. There are no constraints defined To provide specific recommendations for indexes, I would need to know what query you're trying to optimize. However, here are some general index recommendations for the `actors` table: ## Recommended Indexes: 1. **Primary Key Index** (most important): ```sql ALTER TABLE actors ADD PRIMARY KEY (id); ``` This creates a unique index on the `id` column and ensures data integrity. 2. **Name Index** (if you frequently search by actor name): ```sql CREATE INDEX idx_actors_name ON actors(name); ``` This would speed up queries like `WHERE name = 'John Doe'` or `WHERE name LIKE 'John%'` 3. **Sex Index** (if you frequently filter by sex): ```sql CREATE INDEX idx_actors_sex ON actors(sex); ``` This would help with queries like `WHERE sex = 'M'` 4. **Composite Index** (if you often query by both name and sex): ```sql CREATE INDEX idx_actors_name_sex ON actors(name, sex); ``` This would optimize queries like `WHERE name = 'Jane' AND sex = 'F'` Could you share the specific query you're trying to optimize? That would help me provide more targeted index recommendations. |
Although it’s a simple example, it helps illustrate both the potential AI has in retrieving and making sense of data in a private database and the rough edges that require further development.
How about you, what would you ask AI if it could access your database? Also, check out part two, How Can AI Talk to My Database Part Two: MySQL and Gemini.