{"slug": "an-mcp-2026-07-28-server-from-scratch", "title": "An MCP 2026-07-28 Server from Scratch", "summary": "On 2026-07-28, the latest version of the Model Context Protocol (MCP) was published, introducing a stateless design with no initialization handshake and requiring servers to advertise functionality via server/discover. The protocol enables AI assistants to connect to external tools and data sources, and a new blog post demonstrates implementing an MCP server providing read-only access to a SQLite database of 11,686 OpenAlex papers, along with a simple client. The server communicates over standard input/output using JSON-RPC 2.0 messages, with each request carrying a unique id for correlation.", "body_md": "An Air Force pilot\n 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?\n\nThe answer to both questions is the \nModel Context Protocol (MCP)\n , a protocol designed to provide a common way for AI assistants to connect to external tools and information.\n\nMCP goes well beyond tool calling and defines a common language for communication between a \nclient\n and a \nserver\n that is coordinated by a \nhost\n application. The agentic \nharness\n that the model inhabits is the host application that implements the client and interacts with the server.\n\nIf you’ve ever been around distributed computing, you’ll notice more than a few similarities with \nRemote Procedure Calls\n and Java’s \nRemote Method Invocation\n. 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.\n\nMCP has a universal interface to different tools and data sources\n\nThe latest MCP version was just published on\n 2026-07-28\n 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 \nserver/discover\n.\n\nIn 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 \nOpenAlex\n papers, as well as a simple client to demonstrate the servers functionality.\n\nThe 10,000 foot view\n\nMCP messages are \nJSON-RPC 2.0\n formatted and, conceptually, uses good old UNIX \nstdio\n to send and receive messages.\n\nMCP over stdio: the host writes JSON-RPC requests to the server subprocess stdin, reads results from stdout, and reads logs from stderr\n\nThis 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 \nstreamable HTTP\n in a future post, but the picture remains fundamentally the same. The server receives messages through one channel, \nstdin\n , and generates outputs in \nstdout\n. A third stream, \nstderr\n , can be used for error messages and diagnostic information. Closing \nstdin\n is the portable shutdown signal and stdout belongs exclusively to the protocol, no other messages are allowed.\n\nMCP 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 \nid\n 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.\n\nHere is what a simple first request looks like:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"method\": \"server/discover\",\n  \"params\": {\n    \"_meta\": {\n      \"io.modelcontextprotocol/protocolVersion\": \"2026-07-28\",\n      \"io.modelcontextprotocol/clientCapabilities\": {},\n      \"io.modelcontextprotocol/clientInfo\": {\n        \"name\": \"notebook-mini-client\",\n        \"version\": \"0.2\"\n      }\n    }\n  }\n}\n```\n\nWe should note that the \nserver/discover\n method is just a way of asking the server what its capabilities are. It doesn’t start a session, or initialize a connection.\n\nThe response sent back by our server is:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"result\": {\n    \"resultType\": \"complete\",\n    \"supportedVersions\": [\"2026-07-28\"],\n    \"capabilities\": {\"tools\": {}, \"resources\": {}},\n    \"instructions\": \"Call list_tables, then describe_table before composing joins. Use search_works to enter the graph from natural-language concepts.\",\n    \"_meta\": {\n      \"io.modelcontextprotocol/serverInfo\": {\n        \"name\": \"openalex-sqlite\",\n        \"version\": \"0.2.0\"\n      }\n    },\n    \"ttlMs\": 3600000,\n    \"cacheScope\": \"public\"\n  }\n}\n```\n\nThe \ninstructions\n 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: \nttlMs\n states how long a client may treat this result as up to date, and \ncacheScope \ndefines the caching policy that can be used:\n “public”\n means that there is nothing authorization-specific in the response and that is can be shared across users, while \n”private”\n means that the result is user specific.\n\nPreparing the dataset\n\nFor 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 \nOpenAlex\n, the \nCC0\n 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.\n\nThe 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.\n\nBy separating data \nacquisition\n from data \nmodeling\n , 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:\n\nThe OpenAlex subset schema: works at the centre, joined to sources, topics, authors and institutions through link tables, with a citations self-loop\n\nWe implement the entire transformation pipeline in a standalone script, \ncreate_openalex_db.py\n that you can run to generate the database the MCP server will use. The final mysql database is also included in the GitHub repo.\n\nIn 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:\n\n```\ndef reconstruct_abstract(inverted_index):\n    if not inverted_index:\n        return None\n    positioned = [(pos, word)\n                  for word, positions in inverted_index.items()\n                  for pos in positions]\n    return \" \".join(word for _, word in sorted(positioned))\n```\n\nAbout 20% of the papers have no abstract, so we return \nNone\n in those cases. Duplicates (the same author appears on thousands of works) are dropped by using \nINSERT OR IGNORE\n 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 \nBM25\n-ranked search over titles and reconstructed abstracts.\n\nData cleaning\n\nUsing 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.\n\nLet’s start with a missing-data audit:\n\nFull-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.\n\nThen the trap that no amount of schema documentation prevents on its own:\n\nGlobal versus in-subset citation CCDFs on log-log axes; the global distribution extends past 1,000 citations while in-subset counts stop near 40\n\nEach paper carries two citation numbers, a global count straight from OpenAlex in \ncited_by_count\n and the in-degree in the \ncitations\n table that counts only citations within the data slice. While 29.1% of works have zero global citations, over 74% have no internal ones.\n\nBoth 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.\n\nWhen 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.\n\nWorks per year, with the final partial year highlighted\n\nThese issues are why the server implements a \ndescribe_table\n tool that returns foreign keys and sample rows rather than just column names.\n\nA syntactically successful tool can still produce a confident lie.\n\nTool design\n\nTool 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:\n\nlist_tables\n - what entities exist, and how large are they?\n\ndescribe_table\n - what columns and foreign keys can I use?\n\nquery\n - run a read-only \nSELECT/WITH\n statement.\n\nfetch_page\n - for paginating through results\n\nsearch_works\n - the natural-language entry point via BM25-ranked text search.\n\nlist_tables\n and \ndescribe_table\n 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.\n\nOne general \nquery\n lets the model answer questions you never anticipated. A safer alternative would be to use narrow tools instead; \ntop_institutions\n, \nworks_by_year\n, 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.\n\nEvery tool includes a pydantic-like \ninputSchema\n 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.\n\n```\n\"query\": {\n    \"handler\": tool_query,\n    \"description\": (\"Run one read-only SQL statement (SELECT or WITH). \"\n                    \"Returns the first page of rows plus a `handle` \"\n                    \"when more are available.\"),\n    \"inputSchema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"sql\": {\"type\": \"string\", \"minLength\": 1,\n                    \"description\": \"a single SELECT or WITH query\"},\n            \"page_size\": {\"type\": \"integer\", \"minimum\": 1,\n                          \"maximum\": PAGE_SIZE_MAX,\n                          \"default\": PAGE_SIZE_DEFAULT},\n        },\n        \"required\": [\"sql\"],\n        \"additionalProperties\": False,\n    },\n},\n```\n\nHowever, 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.\n\n```\ndef validate_tool_arguments(name, args):\n    \"\"\"Advertising an ``inputSchema`` helps clients and models construct\n    calls; it does not absolve the server from validating untrusted\n    arguments.\"\"\"\n```\n\nTool descriptions are documentation written \nfor the model\n. 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.\n\nThe 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, \ntools/list\n also carries a public TTL. There is no reason for a host to re-ask for an unchanged catalog on every turn.\n\nMCP’s list methods (\ntools/list\n and \nresources/list\n) support protocol level \nnextCursor\n 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.\n\nEvery successful result in modern MCP gets processed through one helper function that wraps it in the correct JSON format:\n\n```\ndef complete_result(payload, *, cacheable=False, cache_scope=\"public\"):\n    \"\"\"Wrap an operation payload in the MCP 2026-07-28 result envelope.\"\"\"\n    result = {\n        \"resultType\": \"complete\",\n        **payload,\n        \"_meta\": {\"io.modelcontextprotocol/serverInfo\": SERVER_INFO},\n    }\n    if cacheable:\n        result[\"ttlMs\"] = CACHE_TTL_MS\n        result[\"cacheScope\"] = cache_scope\n    return result\n```\n\nThe \nresultType\n field provides information about the kind of result we are providing. \n“complete”\n means the request has reached the final result, while \n”input_required”\n 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 \n”input_required”\n result, the client would then retry the \noriginal\n method under a \nnew\n JSON-RPC id, while including the requested information under \ninputResponses\n and \nrequestState\n.\n\nSuccessful \ntools/call\n returns its payload twice:\n\n```\nreturn complete_result({\n    \"content\": [{\"type\": \"text\", \"text\": json.dumps(payload, indent=2)}],\n    \"structuredContent\": payload,\n    \"isError\": False,\n})\n```\n\nstructuredContent\n is machine-readable server output. Despite the name, it is unrelated with an LLM provider’s “structured outputs” or constrained decoding. The \ntext\n block is a backwards-compatible representation and the model-readable fallback. Both audiences get served without either having to parse the other’s format.\n\nMCP also lets a tool advertise an \noutputSchema\n. Output schemas let both ends validate that the \nstructuredContent\n 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.\n\nTo Err is… agentic\n\nSh…errors happen and how you deal with them depends on where they occur. A \nJSON-RPC error\n means the error occurred at the protocol level, and the operation was never validly invoked: unknown method, unknown tool, malformed request, missing \n_meta\n, 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 \nisError\n field set to \ntrue\n. In this case, \nresultType\n is set to \n“complete”\n since the \nprotocol request\n reached a final answer; \nisError\n signals whether the tool succeeded.\n\n```\nexcept (ValueError, sqlite3.Error) as exc:\n    # Expected execution failures are tool results the model can repair.\n    # Unexpected programming failures bubble to main as JSON-RPC -32603.\n    log(f\"tool {name} failed:\", exc)\n    return complete_result({\n        \"content\": [{\"type\": \"text\", \"text\": f\"error: {exc}\"}],\n        \"isError\": True,\n    })\n```\n\nThe 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.\n\nEnforcing the guardrails\n\nTrust \nand\n 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.\n\nNever 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:\n\n```\ndef open_db(path=DB_PATH):\n    # mode=ro: SQLite itself refuses writes -- a *capability* restriction.\n    # The model never gets a connection that could write, so prompt\n    # injection cannot escalate into data modification.\n    uri = Path(path).resolve().as_uri() + \"?mode=ro\"\n    conn = sqlite3.connect(uri, uri=True, check_same_thread=False)\n    conn.execute(\"PRAGMA query_only = ON;\")  # belt on top of braces\n    conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, SQL_VALUE_MAX_BYTES)\n    conn.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, SQL_TEXT_MAX_BYTES)\n    return conn\n```\n\nAs 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.\n\n```\n# UX guard only -- fast, clear feedback for the model. \nif not sql.lstrip().lower().startswith((\"select\", \"with\")):\n    raise ValueError(\"only SELECT / WITH queries are allowed\")\n```\n\nRead-only does not mean harmless\n\nEven 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:\n\nPython’s \nsqlite3.execute()\n raises an error if the SQL contains more than one statement, which kills stacked-query tricks outright.\n\nA 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.\n\nSQL text capped at 100 KB and any single computed value at 1 MB.\n\nBounded pages at 50 rows by default and clipped values at 400 characters, so one enormous abstract cannot dominate a response.\n\nSQL placeholders cannot bind table names, so \ndescribe_table\n checks the name against SQLite’s own catalog:\n\nknown = {t[“name”] for t in tool_list_tables(conn, {})[“tables”]}\nif table not in known:\nraise ValueError(f”unknown table {table!r}; call list_tables first”)\n\nStateless does not mean state-free\n\nThe \nMCP 2026 specification\n 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 \nhandle\n value that can act effectively as a bearer token.\n\n```\n{\n  \"columns\": [\"institution\", \"works\"],\n  \"rows\": [[\"Centre National de la Recherche Scientifique\", 643],\n           [\"Chinese Academy of Sciences\", 283],\n           [\"Tsinghua University\", 216],\n           [\"University of Chinese Academy of Sciences\", 173],\n           [\"University of Science and Technology of China\", 160]],\n  \"row_count\": 5,\n  \"done\": false,\n  \"handle\": \"9e0b6b4b19754ad68e0af122b6f6807e\",\n  \"next\": \"pass `handle` to fetch_page for more rows\"\n}\n```\n\nThe model can pass that value into \nfetch_page\n 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.\n\nPut it to the test\n\nThe 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 \nMiniClient \nthat purposefully offers no abstraction over the underlying protocol.\n\n```\ndef request(self, method, params=None, add_meta=True):\n    \"\"\"Send one self-describing request and wait for its response.\"\"\"\n    self.next_id += 1\n    params = dict(params or {})\n    if add_meta:\n        params[\"_meta\"] = {\n            \"io.modelcontextprotocol/protocolVersion\": self.protocol_version,\n            \"io.modelcontextprotocol/clientCapabilities\": {},\n            \"io.modelcontextprotocol/clientInfo\": {\n                \"name\": \"notebook-mini-client\", \"version\": \"0.2\"},\n        }\n    msg = {\"jsonrpc\": \"2.0\", \"id\": self.next_id,\n           \"method\": method, \"params\": params}\n    self.proc.stdin.write(json.dumps(msg) + \"\\n\")\n    self.proc.stdin.flush()\n    while True:\n        line = self.proc.stdout.readline()\n        response = json.loads(line)\n        # Match by id, never by arrival order. A concurrent client would\n        # route unmatched responses/notifications instead of discarding.\n        if response.get(\"id\") == self.next_id:\n            return response\n```\n\nThe \nrequest()\n method adds version and capabilities onto every message, as required by the protocol. And responses are matched by \nid\n and never by arrival order.\n\nOur simple client learns the system exactly as an agent would: discover, list tools, orient with \nlist_tables\n and \ndescribe_table\n. 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.\n\nThe 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 \n__file__\n:\n\n```\nDB_PATH = Path(__file__).resolve().parents[1] / \"data/openalex.db\"\n```\n\nThe server’s stderr is its diary. Startup, every tool failure, and shutdown all land there, where they cannot corrupt stdout:\n\n```\n[openalex-mcp] serving .../data/openalex.db over MCP 2026-07-28 stdio\n[openalex-mcp] tool query failed: only SELECT / WITH queries are allowed\n[openalex-mcp] tool query failed: SQL error: attempt to write a readonly database\n[openalex-mcp] tool query failed: SQL error: no such column: institution_name\n[openalex-mcp] tool query failed: 'page_size' must be >= 1\n[openalex-mcp] stopped\n```\n\nPlug it into a third party host\n\nA 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 \n.mcp.json\n file with this contents, claude will just detect the new MCP server.\n\n```\n{\n  \"mcpServers\": {\n    \"openalex\": {\n      \"command\": \"/ABSOLUTE/PATH/TO/python\",\n      \"args\": [\n        \"mcp_server/mcp_openalex_adapter.py\"\n      ]\n    }\n  }\n}\n```\n\nUnfortunately, as of this writing (Aug 25), \nMCP 2026-07-28\n is less than a month old so support is still \nvery\n limited. Since the old version is getting deprecated, instead of making our server able to handle both versions, we created a simple wrapper: \nmcp_openlatex_adapter.py\n 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:\n\nSo we can just ask Claude to do some analysis for us, and it will figure out what it needs to do:\n\nWhere to go from here\n\nOur home baked from-scratch server is deliberately narrow. A production version should use the \nofficial SDK\n and add, as requirements actually demand:\n\noutputSchema\n \ndefinitions\n before anything depends on your payloads as an API;\n\nConcurrent dispatch and real cancellation:\n track in-flight request ids and interrupt the matching operation, rather than relying on a database deadline;\n\nStreamable HTTP\n : so you can connect to the server from a different machine;\n\nMulti Round-Trip Requests\n : return \nresultType: “input_required”\n with \ninputRequests\n, and let the client retry under a new id;\n\nWe’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.", "url": "https://wpnews.pro/news/an-mcp-2026-07-28-server-from-scratch", "canonical_source": "https://data4sci.com/blog/an-mcp-server-from-scratch", "published_at": "2026-08-25 17:23:00+00:00", "updated_at": "2026-08-25 17:48:04.022805+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Model Context Protocol", "OpenAlex", "SQLite", "JSON-RPC 2.0"], "alternates": {"html": "https://wpnews.pro/news/an-mcp-2026-07-28-server-from-scratch", "markdown": "https://wpnews.pro/news/an-mcp-2026-07-28-server-from-scratch.md", "text": "https://wpnews.pro/news/an-mcp-2026-07-28-server-from-scratch.txt", "jsonld": "https://wpnews.pro/news/an-mcp-2026-07-28-server-from-scratch.jsonld"}}