# An MCP 2026-07-28 Server from Scratch

> Source: <https://data4sci.com/blog/an-mcp-server-from-scratch>
> Published: 2026-08-25 17:23:00+00:00

An Air Force pilot
 can’t do much with their fighter jet. Similarly, Agents are much less powerful without access to the tools they need to accomplish the tasks we assign to them. But how can agents discover which tools are available and learn to use them? How can you advertise the tools your company has made available to facilitate agents work?

The answer to both questions is the 
Model Context Protocol (MCP)
 , a protocol designed to provide a common way for AI assistants to connect to external tools and information.

MCP goes well beyond tool calling and defines a common language for communication between a 
client
 and a 
server
 that is coordinated by a 
host
 application. The agentic 
harness
 that the model inhabits is the host application that implements the client and interacts with the server.

If you’ve ever been around distributed computing, you’ll notice more than a few similarities with 
Remote Procedure Calls
 and Java’s 
Remote Method Invocation
. And if you haven’t, you can think of MCP as the USB of the agentic world: a universal way to plugin new functionality to your agent.

MCP has a universal interface to different tools and data sources

The latest MCP version was just published on
 2026-07-28
 and marks a significant departure from the previous versions towards a more modern and stateless approach. Requests are independent with no initialization handshake and no protocol-level session. Servers must actively advertise their functionality using 
server/discover
.

In this post we will implement a functioning MCP server, following the latest version of the protocol, to provide read-only access to a SQLite database of 11,686 
OpenAlex
 papers, as well as a simple client to demonstrate the servers functionality.

The 10,000 foot view

MCP messages are 
JSON-RPC 2.0
 formatted and, conceptually, uses good old UNIX 
stdio
 to send and receive messages.

MCP over stdio: the host writes JSON-RPC requests to the server subprocess stdin, reads results from stdout, and reads logs from stderr

This is pretty straightforward if the server, host, and client all live in the same machine (as in our simple example). We’ll explore the more general case, where messages are sent and received through 
streamable HTTP
 in a future post, but the picture remains fundamentally the same. The server receives messages through one channel, 
stdin
 , and generates outputs in 
stdout
. A third stream, 
stderr
 , can be used for error messages and diagnostic information. Closing 
stdin
 is the portable shutdown signal and stdout belongs exclusively to the protocol, no other messages are allowed.

MCP keeps things as simple as possible, but not simpler. The protocol allows for a single UTF-8 JSON object per line, with a newline as the frame delimiter. As the protocol is stateless, requests must carry a unique 
id
 and the response must include it to identify what request it’s responding to. There are no ordering guarantees in JSON-RPC so ids are the only way to correlate inputs and outputs.

Here is what a simple first request looks like:

```
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {
        "name": "notebook-mini-client",
        "version": "0.2"
      }
    }
  }
}
```

We should note that the 
server/discover
 method is just a way of asking the server what its capabilities are. It doesn’t start a session, or initialize a connection.

The response sent back by our server is:

```
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": {"tools": {}, "resources": {}},
    "instructions": "Call list_tables, then describe_table before composing joins. Use search_works to enter the graph from natural-language concepts.",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "openalex-sqlite",
        "version": "0.2.0"
      }
    },
    "ttlMs": 3600000,
    "cacheScope": "public"
  }
}
```

The 
instructions
 field is where the server gets to say “here is how to use me well” without occupying a slot in every tool description. Caching information is included in two fields: 
ttlMs
 states how long a client may treat this result as up to date, and 
cacheScope 
defines the caching policy that can be used:
 “public”
 means that there is nothing authorization-specific in the response and that is can be shared across users, while 
”private”
 means that the result is user specific.

Preparing the dataset

For an MCP server to be useful, it must provide some functionality that the agent can consume. Our toy server will provide a read only interface to a subset of 
OpenAlex
, the 
CC0
 catalog of the global research system. OpenAlex is a genuine entity graph relating works, authors, institutions, venues, topics and citations allowing for non-trivial examples.

The complete dataset clocks in at 1.4 TiB, so we will use just a slice corresponding to documents matching ”scaling laws” published after to select a coherent, laptop-sized corpus. For the sake of reproducibility, we cache the raw gzipped JSON files before performing any transformations modeling.

By separating data 
acquisition
 from data 
modeling
 , we can iterate the database without having to re-download anything. It also means you can run the entire notebook without an API key. :o) Our final dataset has 9 tables as shown in the image below:

The OpenAlex subset schema: works at the centre, joined to sources, topics, authors and institutions through link tables, with a citations self-loop

We implement the entire transformation pipeline in a standalone script, 
create_openalex_db.py
 that you can run to generate the database the MCP server will use. The final mysql database is also included in the GitHub repo.

In order to avoid issues with publishers, OpenAlex distributes abstracts as an inverted index, mapping words to positions, rather than prose. Our approach relies on using the original text, so we reconstruct the original text while building the database:

```
def reconstruct_abstract(inverted_index):
    if not inverted_index:
        return None
    positioned = [(pos, word)
                  for word, positions in inverted_index.items()
                  for pos in positions]
    return " ".join(word for _, word in sorted(positioned))
```

About 20% of the papers have no abstract, so we return 
None
 in those cases. Duplicates (the same author appears on thousands of works) are dropped by using 
INSERT OR IGNORE
 rather than by stateful bookkeeping. We restrict the citations table to internal citations so that we can always look at the cited work. Finally, a standalone FTS5 table provides us with full text 
BM25
-ranked search over titles and reconstructed abstracts.

Data cleaning

Using an MCP server providing erroneous data helps no one, so before building the server, the notebook performs some sanity checks over the corpus to identify any blind spots we might have missed.

Let’s start with a missing-data audit:

Full-text search silently skips all the works with no abstract, and institution rankings only include what’s present in the subset. All of these issues are dataset specific and orthogonal to the server layer, but it can be managed. We surface this information in tool descriptions and host instructions to make sure that the model knows about it.

Then the trap that no amount of schema documentation prevents on its own:

Global versus in-subset citation CCDFs on log-log axes; the global distribution extends past 1,000 citations while in-subset counts stop near 40

Each paper carries two citation numbers, a global count straight from OpenAlex in 
cited_by_count
 and the in-degree in the 
citations
 table that counts only citations within the data slice. While 29.1% of works have zero global citations, over 74% have no internal ones.

Both numbers are correct, but they answer different questions. ”How many citations does this paper have?”* must come from the global column; while ”who cites it?”* can only be answered internally. An agent that conflates them produces a fluent, well-formatted, entirely false analysis.

When we look at the number of works per year, the bar corresponding to 2026 covers only a partial year. The censored year is the tallest bar, so the naive reading understates growth rather than inventing a decline. Any trend claim an agent makes has to account for this.

Works per year, with the final partial year highlighted

These issues are why the server implements a 
describe_table
 tool that returns foreign keys and sample rows rather than just column names.

A syntactically successful tool can still produce a confident lie.

Tool design

Tool design is API design, for a consumer that is both very capable and hopelessly ignorant of your data schema. Our server provides five unique tools:

list_tables
 - what entities exist, and how large are they?

describe_table
 - what columns and foreign keys can I use?

query
 - run a read-only 
SELECT/WITH
 statement.

fetch_page
 - for paginating through results

search_works
 - the natural-language entry point via BM25-ranked text search.

list_tables
 and 
describe_table
 are orientation primitives. Without them, the model must either guess your schema or receive the entire DDL inside every tool description, on every turn. These tools allows us to make discovery cheap and explicit instead.

One general 
query
 lets the model answer questions you never anticipated. A safer alternative would be to use narrow tools instead; 
top_institutions
, 
works_by_year
, etc. Each would be safer but collectively they would be far less useful as it would limit the types of questions the MCP server can answer.

Every tool includes a pydantic-like 
inputSchema
 that provides the LLM with information of the task the tool performs, the number and types of arguments, etc to help the model generate valid tool calls.

```
"query": {
    "handler": tool_query,
    "description": ("Run one read-only SQL statement (SELECT or WITH). "
                    "Returns the first page of rows plus a `handle` "
                    "when more are available."),
    "inputSchema": {
        "type": "object",
        "properties": {
            "sql": {"type": "string", "minLength": 1,
                    "description": "a single SELECT or WITH query"},
            "page_size": {"type": "integer", "minimum": 1,
                          "maximum": PAGE_SIZE_MAX,
                          "default": PAGE_SIZE_DEFAULT},
        },
        "required": ["sql"],
        "additionalProperties": False,
    },
},
```

However, a schema is just a description of what’s included, but it enforces nothing. A malicious or buggy client can ignore it completely, so the server must validate required fields, types, string lengths, integer bounds, and unknown properties server-side, before any runs are actually performed.

```
def validate_tool_arguments(name, args):
    """Advertising an ``inputSchema`` helps clients and models construct
    calls; it does not absolve the server from validating untrusted
    arguments."""
```

Tool descriptions are documentation written 
for the model
. They constitute your first defense against wrong guesses. Server-side validation is the final authority on whether it’s ok to perform the tool call or not.

The tool catalog is returned sorted deterministically. Hosts frequently include tool definitions in every prompt, so stable ordering is the difference between hitting and missing the upstream prompt cache. Because our list is static and identical for everyone, 
tools/list
 also carries a public TTL. There is no reason for a host to re-ask for an unchanged catalog on every turn.

MCP’s list methods (
tools/list
 and 
resources/list
) support protocol level 
nextCursor
 pagination. Our server supports only five tools and a single resource, so neither list has a second page and we explicitly reject a cursor rather than silently ignoring one.

Every successful result in modern MCP gets processed through one helper function that wraps it in the correct JSON format:

```
def complete_result(payload, *, cacheable=False, cache_scope="public"):
    """Wrap an operation payload in the MCP 2026-07-28 result envelope."""
    result = {
        "resultType": "complete",
        **payload,
        "_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
    }
    if cacheable:
        result["ttlMs"] = CACHE_TTL_MS
        result["cacheScope"] = cache_scope
    return result
```

The 
resultType
 field provides information about the kind of result we are providing. 
“complete”
 means the request has reached the final result, while 
”input_required”
 is used when the server needs more from the client or the user. A pattern known as a Multi Round-Trip Request (MRTR) pattern. On receiving an 
”input_required”
 result, the client would then retry the 
original
 method under a 
new
 JSON-RPC id, while including the requested information under 
inputResponses
 and 
requestState
.

Successful 
tools/call
 returns its payload twice:

```
return complete_result({
    "content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
    "structuredContent": payload,
    "isError": False,
})
```

structuredContent
 is machine-readable server output. Despite the name, it is unrelated with an LLM provider’s “structured outputs” or constrained decoding. The 
text
 block is a backwards-compatible representation and the model-readable fallback. Both audiences get served without either having to parse the other’s format.

MCP also lets a tool advertise an 
outputSchema
. Output schemas let both ends validate that the 
structuredContent
 follows the expected format, and you want them before anything depends on your payloads as a stable API. We omit them in this tutorial for the sake of simplicity.

To Err is… agentic

Sh…errors happen and how you deal with them depends on where they occur. A 
JSON-RPC error
 means the error occurred at the protocol level, and the operation was never validly invoked: unknown method, unknown tool, malformed request, missing 
_meta
, unsupported version, bad resource URI. When the protocol is used correctly, but the tool fails, the agent returns a successful JSON-RPC result with the 
isError
 field set to 
true
. In this case, 
resultType
 is set to 
“complete”
 since the 
protocol request
 reached a final answer; 
isError
 signals whether the tool succeeded.

```
except (ValueError, sqlite3.Error) as exc:
    # Expected execution failures are tool results the model can repair.
    # Unexpected programming failures bubble to main as JSON-RPC -32603.
    log(f"tool {name} failed:", exc)
    return complete_result({
        "content": [{"type": "text", "text": f"error: {exc}"}],
        "isError": True,
    })
```

The distinction is important. Hosts may feed tool errors into the LLM context so that the model can repair its own call. A JSON-RPC error points to a client bug and not something that the model can fix by thinking harder.

Enforcing the guardrails

Trust 
and
 verify. The model sees untrusted user text and retrieved data, which is potentially attacker-influenceable. Indirect prompt injection is a real possibility when you’re dealing with arbitrary text from the internet.

Never rely on the model behaving and following instructions accurately. A tool description that says “only run SELECT” is not a security control, the connection itself must not be able to perform the disallowed action. In our example, we set the connection to the database to be read-only:

```
def open_db(path=DB_PATH):
    # mode=ro: SQLite itself refuses writes -- a *capability* restriction.
    # The model never gets a connection that could write, so prompt
    # injection cannot escalate into data modification.
    uri = Path(path).resolve().as_uri() + "?mode=ro"
    conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
    conn.execute("PRAGMA query_only = ON;")  # belt on top of braces
    conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, SQL_VALUE_MAX_BYTES)
    conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, SQL_TEXT_MAX_BYTES)
    return conn
```

As an additional layer, we also implement a quick prefix check that can provide fast and legible feedback to the model. Even if a clever prompt sneaks past this check, the read-only SQLite connections refuses the write.

```
# UX guard only -- fast, clear feedback for the model. 
if not sql.lstrip().lower().startswith(("select", "with")):
    raise ValueError("only SELECT / WITH queries are allowed")
```

Read-only does not mean harmless

Even with the guardrails above, an adversarial agent can still cause damage. It can read every row accessible, infer sensitive facts by aggregation, and burn your CPU. The server is design takes this into account:

Python’s 
sqlite3.execute()
 raises an error if the SQL contains more than one statement, which kills stacked-query tricks outright.

A five-second wall-clock limit that aborts the call when the clock runs out. This is the only reliable way to prevent an accidental cross join inside a single-threaded server, without threads or signals.

SQL text capped at 100 KB and any single computed value at 1 MB.

Bounded pages at 50 rows by default and clipped values at 400 characters, so one enormous abstract cannot dominate a response.

SQL placeholders cannot bind table names, so 
describe_table
 checks the name against SQLite’s own catalog:

known = {t[“name”] for t in tool_list_tables(conn, {})[“tables”]}
if table not in known:
raise ValueError(f”unknown table {table!r}; call list_tables first”)

Stateless does not mean state-free

The 
MCP 2026 specification
 says a server must not infer context from a connection, but it does not say that a server can not have state. Our SQL cursor can spans multiple calls. The first page returns a full UUID4 
handle
 value that can act effectively as a bearer token.

```
{
  "columns": ["institution", "works"],
  "rows": [["Centre National de la Recherche Scientifique", 643],
           ["Chinese Academy of Sciences", 283],
           ["Tsinghua University", 216],
           ["University of Chinese Academy of Sciences", 173],
           ["University of Science and Technology of China", 160]],
  "row_count": 5,
  "done": false,
  "handle": "9e0b6b4b19754ad68e0af122b6f6807e",
  "next": "pass `handle` to fetch_page for more rows"
}
```

The model can pass that value into 
fetch_page
 to continue the request. By default handles are valid for 5 minutes but the counter gets reset whenever it gets used. If a request uses an unknown or expired value a tool error is returned.

Put it to the test

The best way to test a from-scratch server is a from-scratch client, so you can see both ends of the wire at once. This posts notebook implements a 
MiniClient 
that purposefully offers no abstraction over the underlying protocol.

```
def request(self, method, params=None, add_meta=True):
    """Send one self-describing request and wait for its response."""
    self.next_id += 1
    params = dict(params or {})
    if add_meta:
        params["_meta"] = {
            "io.modelcontextprotocol/protocolVersion": self.protocol_version,
            "io.modelcontextprotocol/clientCapabilities": {},
            "io.modelcontextprotocol/clientInfo": {
                "name": "notebook-mini-client", "version": "0.2"},
        }
    msg = {"jsonrpc": "2.0", "id": self.next_id,
           "method": method, "params": params}
    self.proc.stdin.write(json.dumps(msg) + "\n")
    self.proc.stdin.flush()
    while True:
        line = self.proc.stdout.readline()
        response = json.loads(line)
        # Match by id, never by arrival order. A concurrent client would
        # route unmatched responses/notifications instead of discarding.
        if response.get("id") == self.next_id:
            return response
```

The 
request()
 method adds version and capabilities onto every message, as required by the protocol. And responses are matched by 
id
 and never by arrival order.

Our simple client learns the system exactly as an agent would: discover, list tools, orient with 
list_tables
 and 
describe_table
. It also runs a deduplicated query, paginates by handle, performs a full-text seach, attacks the boundary four ways, reads the schema resource, probes the version boundary, and finally shuts down by closing stdin.

The LLM repository includes a battery of unit tests that launch the server from an temporary working directory and which catches a failure interactive demos systematically miss. Hosts make no promises about the working directory they start your subprocess from, which is why the server resolves its database relative to its own 
__file__
:

```
DB_PATH = Path(__file__).resolve().parents[1] / "data/openalex.db"
```

The server’s stderr is its diary. Startup, every tool failure, and shutdown all land there, where they cannot corrupt stdout:

```
[openalex-mcp] serving .../data/openalex.db over MCP 2026-07-28 stdio
[openalex-mcp] tool query failed: only SELECT / WITH queries are allowed
[openalex-mcp] tool query failed: SQL error: attempt to write a readonly database
[openalex-mcp] tool query failed: SQL error: no such column: institution_name
[openalex-mcp] tool query failed: 'page_size' must be >= 1
[openalex-mcp] stopped
```

Plug it into a third party host

A stdio launch configuration straightforward. The host will be the one starting the process, so you must use absolute paths for both the interpreter and the script. If your project has a 
.mcp.json
 file with this contents, claude will just detect the new MCP server.

```
{
  "mcpServers": {
    "openalex": {
      "command": "/ABSOLUTE/PATH/TO/python",
      "args": [
        "mcp_server/mcp_openalex_adapter.py"
      ]
    }
  }
}
```

Unfortunately, as of this writing (Aug 25), 
MCP 2026-07-28
 is less than a month old so support is still 
very
 limited. Since the old version is getting deprecated, instead of making our server able to handle both versions, we created a simple wrapper: 
mcp_openlatex_adapter.py
 around our modern server so that we can use it from inside claude. With the file above in the repository, claude detects the MCP server automatically:

So we can just ask Claude to do some analysis for us, and it will figure out what it needs to do:

Where to go from here

Our home baked from-scratch server is deliberately narrow. A production version should use the 
official SDK
 and add, as requirements actually demand:

outputSchema
 
definitions
 before anything depends on your payloads as an API;

Concurrent dispatch and real cancellation:
 track in-flight request ids and interrupt the matching operation, rather than relying on a database deadline;

Streamable HTTP
 : so you can connect to the server from a different machine;

Multi Round-Trip Requests
 : return 
resultType: “input_required”
 with 
inputRequests
, and let the client retry under a new id;

We’ll build a more robust and feature complete MCP server using the SDK in a future post. The point of implementing it by hand is to make sure we fully grok all the abstractions and how everything works under the hood.
