{"slug": "how-to-build-your-own-mcp-server", "title": "How to build your own MCP server", "summary": "Air Pipe has published a guide demonstrating how to build a production-ready MCP server using a Postgres database, a single config file, and a token, with a setup time of about 15 minutes. The tutorial covers creating a schema with tenant and token tables, seeding data, and configuring environment variables, culminating in a URL that can be pasted into MCP clients like Claude Desktop.", "body_md": "Most MCP tutorials hand you a Node project. You install an SDK, write a tool\n\nhandler, wire up stdio, and end up with something that runs on your laptop as\n\nyou, with your credentials, for exactly one user.\n\nThat's fine for a demo. It's not something you can give a customer.\n\nHere's the other way, end to end: a database, one config file, a token, and a\n\nURL you paste into Claude. Every step below is a real command against a real\n\npack — nothing elided, nothing left as an exercise.\n\n**Time:** about 15 minutes. **You'll need:** an [Air Pipe](https://airpipe.io) account (free tier is\n\nenough), a Postgres database, and an MCP client — Claude Desktop, Claude Code,\n\nCursor, anything that speaks MCP.\n\nIf you already have one, skip ahead. If not, any of these work and all have a\n\nusable free tier:\n\n| Provider | What you get |\n|---|---|\n|\n\n`docker run -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:16`\n\nWhat you need out of it is one connection string:\n\n```\npostgresql://user:password@host:5432/dbname\n```\n\nA local Postgres works for following along, but your managed Air Pipe instance\n\ncan't reach `localhost`\n\n— so if you want the tools live from Claude Desktop, use\n\na hosted database or self-host the Air Pipe binary next to your local one.\n\n**On SSL:** most hosted providers require it. If your first query fails with\n\n`SSL is required`\n\n, append `?sslmode=require`\n\nto the connection string. Neon\n\nneeds this; Supabase includes it in the string it gives you.\n\nThree tables. Only one of them is your data:\n\n```\nCREATE EXTENSION IF NOT EXISTS pgcrypto;\n\n-- A tenant is one of YOUR customers. Ignore it entirely while it's just you;\n-- it's what makes step 8 possible without a rewrite.\nCREATE TABLE IF NOT EXISTS mcp_tenants (\n  id         UUID        PRIMARY KEY DEFAULT gen_random_uuid(),\n  name       TEXT        NOT NULL,\n  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\n-- Issued token metadata — the revocation denylist. The token string itself is\n-- never stored, only its jti claim.\nCREATE TABLE IF NOT EXISTS mcp_tokens (\n  jti        UUID        PRIMARY KEY,\n  tenant_id  UUID        NOT NULL REFERENCES mcp_tenants(id) ON DELETE CASCADE,\n  subject    TEXT        NOT NULL,\n  name       TEXT        NOT NULL DEFAULT 'default',\n  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n  expires_at TIMESTAMPTZ NOT NULL,\n  revoked_at TIMESTAMPTZ\n);\n\n-- The resource your tools read and write. Swap this for your own table.\nCREATE TABLE IF NOT EXISTS mcp_tasks (\n  id         UUID        PRIMARY KEY DEFAULT gen_random_uuid(),\n  tenant_id  UUID        NOT NULL REFERENCES mcp_tenants(id) ON DELETE CASCADE,\n  title      TEXT        NOT NULL,\n  status     TEXT        NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),\n  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\n\nCREATE INDEX IF NOT EXISTS idx_mcp_tasks_tenant ON mcp_tasks (tenant_id, created_at DESC);\n```\n\nRun it:\n\n```\npsql \"$DATABASE_URL\" -f schema.sql\n```\n\n`pgcrypto`\n\nis only needed for `gen_random_uuid()`\n\non Postgres 12 and earlier —\n\nit's built in from 13 on, and the `IF NOT EXISTS`\n\nmakes the line harmless either\n\nway.\n\nSeed a tenant and a couple of rows so there's something to see:\n\n```\nINSERT INTO mcp_tenants (id, name)\nVALUES ('11111111-1111-1111-1111-111111111111', 'Acme Inc');\n\nINSERT INTO mcp_tasks (tenant_id, title, status) VALUES\n  ('11111111-1111-1111-1111-111111111111', 'Ship the MCP launch post', 'open'),\n  ('11111111-1111-1111-1111-111111111111', 'Review Q3 numbers',        'done');\n```\n\nIn the Air Pipe dashboard, under your environment's managed variables (or as\n\n`ap_var`\n\ns if you're self-hosting):\n\n| Name | Value |\n|---|---|\n`DATABASE_URL` |\nthe connection string from step 1 |\n`SOLO_SECRET` |\na 32+ character random string |\n\nGenerate the secret rather than typing one — it's the only thing standing\n\nbetween the internet and your database:\n\n```\nopenssl rand -base64 48\n```\n\nBoth are referenced as `a|ap_var::NAME|`\n\nin the config, so they never appear in\n\nthe file you commit.\n\nHere's the whole thing. One file, two tools.\n\n```\nname: McpTasks\ndescription: MCP tools over Postgres, guarded by a single shared HS256 token.\n\n# Who this server says it is when a client calls initialize (engine >= 1.38.0).\nmcp_servers:\n  tasks:\n    title: Tasks\n    instructions: >-\n      A task list backed by Postgres. Use list_tasks to read tasks (optionally\n      filtered to \"open\" or \"done\") and create_task to add one. Both tools\n      require the bearer token issued by the operator.\n    default: true\n\nglobal:\n  databases:\n    main:\n      driver: postgres\n      conn_string: \"a|ap_var::DATABASE_URL|\"\n\ninterfaces:\n\n  # MCP tool: list_tasks   ·   HTTP: POST /solo/tasks\n  solo/tasks:\n    output: http\n    method: POST\n    summary: List all tasks\n    description: List every task, newest first. Optionally filter by status.\n    tags: [tasks]\n    mcp:\n      enabled: true\n      tool_name: list_tasks\n      description: List all tasks. Optional status filter (\"open\" or \"done\").\n\n    actions:\n      - name: ValidateToken\n        input: a|headers|\n        hide_data_on_success: true\n        assert:\n          http_code_on_error: 401\n          error_message: \"Invalid or missing token\"\n          tests:\n            - value: airpipe-jwt\n              is_not_null: true\n              is_valid_jwt: a|ap_var::SOLO_SECRET|\n        post_transforms:\n          - extract_value: jwt_claims\n\n      - name: CheckBody\n        run_when_succeeded:\n          actions: [ValidateToken]\n          http_code_on_error: 400\n        input: a|body|\n        hide_data_on_success: true\n        assert:\n          tests:\n            - value: status\n              is_not_null: false\n              description: Optional status filter — \"open\" or \"done\".\n\n      - name: ListTasks\n        run_when_succeeded: [CheckBody]\n        database: main\n        query: |\n          SELECT id, title, status, created_at\n          FROM mcp_tasks\n          WHERE ($1::text IS NULL OR status = $1::text)\n          ORDER BY created_at DESC\n          LIMIT 200;\n        params:\n          - a|body::status->default(null)|\n\n  # MCP tool: create_task   ·   HTTP: POST /solo/tasks/create\n  solo/tasks/create:\n    output: http\n    method: POST\n    summary: Create a task\n    tags: [tasks]\n    mcp:\n      enabled: true\n      tool_name: create_task\n      description: Create a new task. Requires a title; status defaults to \"open\".\n\n    actions:\n      - name: ValidateToken\n        input: a|headers|\n        hide_data_on_success: true\n        assert:\n          http_code_on_error: 401\n          error_message: \"Invalid or missing token\"\n          tests:\n            - value: airpipe-jwt\n              is_not_null: true\n              is_valid_jwt: a|ap_var::SOLO_SECRET|\n\n      - name: CheckBody\n        run_when_succeeded:\n          actions: [ValidateToken]\n          http_code_on_error: 400\n        input: a|body|\n        hide_data_on_success: true\n        assert:\n          http_code_on_error: 400\n          error_message: \"title is required\"\n          tests:\n            - value: title\n              is_not_null: true\n              is_not_empty: true\n              description: The task title.\n            - value: status\n              is_not_null: false\n              description: Optional status — \"open\" (default) or \"done\".\n\n      - name: CreateTask\n        run_when_succeeded: [CheckBody]\n        database: main\n        query: |\n          INSERT INTO mcp_tasks (tenant_id, title, status)\n          VALUES ($1::uuid, $2, COALESCE($3, 'open'))\n          RETURNING id, title, status, created_at;\n        params:\n          - \"11111111-1111-1111-1111-111111111111\"\n          - a|CheckBody::title|\n          - a|body::status->default(null)|\n        post_transforms:\n          - extract_value: \"[0]\"\n```\n\nFive things worth pointing at:\n\n** mcp_servers is the server; mcp: blocks are the tools.** The declaration at\n\n**The mcp: block is the only thing that makes it a tool.** Delete it and you\n\n**Auth is not MCP-specific.** Air Pipe takes the client's\n\n`Authorization: Bearer`\n\ntoken, forwards it into the interface as the\n\n`airpipe-jwt`\n\nheader, and runs the same actions an HTTP request would.\n\n**Securing an MCP tool is exactly securing a route.** One model to learn, not\n\ntwo.\n\n** CheckBody is what the AI sees.** The MCP\n\n`inputSchema`\n\nis generated from`description:`\n\n. Write them for`is_not_null: false`\n\nis an always-pass predicate: it declares the field as`CheckBody`\n\nreads `a|body|`\n\n,**Parameters are bound, not interpolated.** `$1`\n\n, `$2`\n\nwith a `params:`\n\nlist —\n\nso a task titled `'); DROP TABLE mcp_tasks; --`\n\nis a task title.\n\nNothing to build and nothing to host.\n\nOn managed Air Pipe, paste the file into the dashboard editor and hit deploy —\n\nthat validates it on the way in. If you're using the Air Pipe MCP tools from\n\nyour own AI client, \"validate and deploy this config\" does the same from the\n\nchat, and installing the pack (below) does it without either.\n\nSelf-hosting is one command — point the binary at the directory holding the\n\nfile:\n\n```\nairpipe server --config-dir . --api-key <your-key>\n```\n\nIt serves on port 4111 by default, so the URLs in the next steps are\n\n`http://localhost:4111/…`\n\n. Run `airpipe login`\n\nonce and you can drop\n\n`--api-key`\n\n.\n\nOnce, at [jwt.io](https://jwt.io): algorithm **HS256**, secret = your\n\n`SOLO_SECRET`\n\n, payload:\n\n```\n{ \"sub\": \"me\", \"exp\": 1798761600 }\n```\n\nCopy the token. Rotating `SOLO_SECRET`\n\ninvalidates it.\n\nPrefer the command line:\n\n``` python\npython3 - <<'PY'\nimport base64, hmac, hashlib, json, os\ndef b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=')\nsecret = os.environ['SOLO_SECRET'].encode()\nmsg = b64(json.dumps({\"alg\":\"HS256\",\"typ\":\"JWT\"}).encode()) + b'.' + \\\n      b64(json.dumps({\"sub\":\"me\",\"exp\":1798761600}).encode())\nsig = b64(hmac.new(secret, msg, hashlib.sha256).digest())\nprint((msg + b'.' + sig).decode())\nPY\n```\n\nDebugging through an MCP client is miserable — a failure shows up as \"the tool\n\ndidn't work.\" Check with curl first. MCP is JSON-RPC over HTTP, so you can\n\ndrive it directly:\n\n```\nBASE=https://your-airpipe-host/<org>/<env>   # self-hosted: no /<org>/<env>\nTOKEN=<the token from step 6>\n\n# List the tools\ncurl -sX POST $BASE/mcp \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}' | jq '.result.tools[].name'\n# → \"list_tasks\"\n# → \"create_task\"\n\n# Call one\ncurl -sX POST $BASE/mcp \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\n       \"params\":{\"name\":\"create_task\",\"arguments\":{\"title\":\"Draft the changelog\"}}}'\n\n# The same tool over plain HTTP — note the header name changes\ncurl -sX POST $BASE/solo/tasks \\\n  -H \"airpipe-jwt: $TOKEN\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"status\":\"open\"}' | jq '.data.ListTasks.data'\n```\n\nIf `tools/list`\n\nreturns your two tools and `tools/call`\n\nreturns a row, you're\n\ndone — everything after this is client configuration.\n\nTwo failures worth naming, because they're the common ones:\n\n`Invalid or missing token`\n\n`SOLO_SECRET`\n\n, or `exp`\n\nis in the past. Decode the token at jwt.io and check\nthe expiry first; it's usually that.`localhost`\n\nis the usual culprit, SSL\nthe other.\n\n```\n{\n  \"mcpServers\": {\n    \"my-tasks\": {\n      \"url\": \"https://your-airpipe-host/<org>/<env>/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer <your-token>\" }\n    }\n  }\n}\n```\n\nClaude Desktop keeps this at\n\n`~/Library/Application Support/Claude/claude_desktop_config.json`\n\non macOS and\n\n`%APPDATA%\\Claude\\claude_desktop_config.json`\n\non Windows. Claude Code:\n\n`claude mcp add --transport http my-tasks https://your-airpipe-host/<org>/<env>/mcp --header \"Authorization: Bearer <token>\"`\n\n.\n\nRestart the client. Ask *\"what's on my task list?\"* and it queries your\n\ndatabase.\n\nYou also have, from that same file and with no extra work: an HTTP endpoint for\n\nthe clients that don't speak MCP, OpenAPI docs, Prometheus metrics, and an\n\nOpenTelemetry trace for every tool call showing which action ran and how long\n\nthe query took. That last one matters more than it sounds — when a model calls\n\na tool and gets a confusing answer, the trace is how you find out whether the\n\ntool was wrong or the model was.\n\nEvery client calls `initialize`\n\nbefore it lists anything, and that response is\n\nwhere the server says who it is. Skip it and yours introduces itself with a\n\nbuilt-in name and no description — a listing that's a bare label above a wall of\n\ntool descriptions. That's what `mcp_servers`\n\nat the top of the config fixes:\n\n``` php\nmcp_servers:\n  tasks:\n    title: Tasks               # -> serverInfo.title, the name in the client's UI\n    instructions: >-           # -> the initialize result's `instructions`\n      A task list backed by Postgres. Use list_tasks to read tasks and\n      create_task to add one. Both require the operator's bearer token.\n    default: true              # adopt every tool that names no server\n```\n\n`instructions`\n\nmatters more than it looks. MCP registries — mcp.so, Glama,\n\nSmithery, PulseMCP — read a remote server's listing description straight off\n\nthat field. There is no other place to write one, so an unlisted description\n\nisn't a blank field somewhere; it's a listing nobody clicks.\n\nCheck it the same way you checked the tools:\n\n```\ncurl -sX POST $BASE/mcp \\\n  -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\n       \"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\n                 \"clientInfo\":{\"name\":\"curl\",\"version\":\"1\"}}}' \\\n  | jq '{title: .result.serverInfo.title, instructions: .result.instructions}'\n```\n\n**Declaring a server and contributing tools to it are separate on purpose.** An\n\nMCP server is a named group of tools, not a property of one config: tools in\n\n*any* of your configs join a server by id, so one identity can cover tools spread\n\nacross many files — which also means the declaration can live alone in its own\n\nconfig (`interfaces: {}`\n\n) and survive whichever tool file you rename next.\n\nThe id is a route segment, so a second declaration is a second endpoint from the\n\nsame deployment — a public server and an internal one, say:\n\n```\nmcp_servers:\n  tasks:                       # served at /mcp\n    title: Tasks\n    default: true\n  tasks-admin:                 # served at /mcp/tasks-admin\n    title: Tasks (admin)\nmcp:\n      enabled: true\n      tool_name: purge_tasks\n      server: tasks-admin      # published only on the named endpoint\n```\n\nIds are 1–64 characters of `a-z`\n\n, `0-9`\n\nor `-`\n\n, only one server may be the\n\ndefault, and a tool naming a server nothing declares publishes on *no* server\n\nrather than the wrong one. Needs engine ≥ 1.38.0.\n\nWorth knowing regardless of what you build with: `tools/call`\n\nruns your code,\n\n`tools/list`\n\ndoesn't.\n\nListing tools returns metadata — names, descriptions, input schemas. Whatever\n\nauth you put inside your handlers never fires for discovery. So a server with\n\nlocked-down calls can still let anyone who knows the URL enumerate every tool\n\nyou expose and its full schema. They can't call anything. They can read the map.\n\nFor a personal server, fine. For an endpoint you offer customers, that catalog\n\nis often the sensitive part — your tool names are a description of your product.\n\nClose it by adding one line per tool, pointing at an interface that re-runs the\n\ntoken check when a client lists tools:\n\n```\n    mcp:\n      enabled: true\n      tool_name: list_tasks\n      list_authorizer: authorize-discovery\n```\n\nAnd the gate itself — an ordinary interface, not a tool:\n\n```\n  authorize-discovery:\n    output: http\n    method: POST\n    summary: Authorize MCP tool discovery for the caller's token.\n    tags: [internal]\n\n    actions:\n      - name: ValidateToken\n        input: a|headers|\n        hide_data_on_success: true\n        assert:\n          http_code_on_error: 401\n          error_message: \"Invalid or missing token\"\n          tests:\n            - value: airpipe-jwt\n              is_not_null: true\n              is_valid_jwt: a|ap_var::SOLO_SECRET|\n        response_on_success:\n          http_code: 200\n```\n\nNow an unauthenticated `tools/list`\n\nreturns `{\"result\":{\"tools\":[]}}`\n\n— not even\n\nthe names.\n\nThe gate is`response_on_success: { http_code: 200 }`\n\nis required.\n\nfail-closed on anything that isn't an explicit 2xx, and an interface whose\n\nactions all succeed leaves the status code unset — which reads as \"not\n\nauthorized\" and hides every gated tooleven for a valid token. If your tools\n\nvanish after adding the gate, this is why.\n\nNeeds engine ≥ 1.7.0. Drop the `list_authorizer:`\n\nline to make discovery public.\n\nEverything above is one token, one grant — everyone who holds it sees every row.\n\nRight for pointing an AI at your own database. Useless the moment you have\n\nusers.\n\nThe multi-tenant shape is the same config with the token doing more work. Your\n\nbackend already knows who's logged in, so it mints a per-user token carrying a\n\n`tenant_id`\n\n:\n\n```\nTOKEN=$(curl -sX POST $BASE/auth/exchange \\\n  -H \"x-exchange-secret: $EXCHANGE_SECRET\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"tenant_id\":\"11111111-1111-1111-1111-111111111111\",\n       \"subject\":\"user-123\",\"name\":\"laptop\"}' \\\n  | jq -r '.data.Result.data.token')\n```\n\nThen every query scopes to the claim in that token instead of a hardcoded id:\n\n```\n      - name: ListTasks\n        database: main\n        query: |\n          SELECT id, title, status, created_at\n          FROM mcp_tasks\n          WHERE tenant_id = $1::uuid\n            AND ($2::text IS NULL OR status = $2::text)\n          ORDER BY created_at DESC\n          LIMIT 200;\n        params:\n          - a|ValidateJwt::tenant_id|\n          - a|body::status->default(null)|\n```\n\nA row from another tenant doesn't match. Cross-tenant access is structurally\n\nimpossible rather than merely forbidden — there's no code path where forgetting\n\na `WHERE`\n\nclause leaks a customer's data, because the filter *is* the query.\n\nOne endpoint, every customer, each seeing only their own rows.\n\nA signature check can't tell a revoked token from a valid one — that's what the\n\n`mcp_tokens`\n\ntable is for. Every tool re-checks the token's `jti`\n\nagainst it:\n\n```\n      - name: CheckTokenActive\n        run_when_succeeded:\n          actions: [ValidateJwt]\n          http_code_on_error: 401\n        database: main\n        hide_data_on_success: true\n        query: |\n          SELECT (\n            $1::uuid IS NULL OR EXISTS (\n              SELECT 1 FROM mcp_tokens\n              WHERE jti = $1::uuid AND revoked_at IS NULL AND expires_at > NOW()\n            )\n          ) AS ok;\n        params:\n          - a|ValidateJwt::jti->default(null)|\n        assert:\n          http_code_on_error: 401\n          error_message: \"Token revoked or expired\"\n          tests:\n            - value: \"[0]ok\"\n              is_equal_to: true\n```\n\nRevoking is a call, not an SSH session:\n\n```\ncurl -sX POST $BASE/auth/revoke \\\n  -H \"x-exchange-secret: $EXCHANGE_SECRET\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"jti\":\"<the jti returned at mint time>\"}'\n```\n\nThe next call is refused: `401 Token revoked or expired`\n\non the HTTP route, and\n\nan error result from the tool over MCP. This is the piece a naive JWT setup\n\nforgets.\n\nSkip the exchange hop entirely. Point `is_valid_jwt`\n\nat your provider's JWKS and\n\nverify their RS256 tokens directly:\n\n```\n            - value: airpipe-jwt\n              is_not_null: true\n              is_valid_jwt:\n                jwks_url: a|ap_var::OIDC_JWKS_URL|\n                alg: RS256\n                iss: a|ap_var::OIDC_ISSUER|\n                aud: a|ap_var::OIDC_AUDIENCE|\n```\n\nAir Pipe fetches and caches the keys, selects the signer by the token's `kid`\n\n,\n\nand enforces `iss`\n\n/ `aud`\n\n/ `exp`\n\n. Provider key rotation just works. Add a\n\n`tenant_id`\n\nclaim in your IdP and the scoping above is unchanged. Needs engine\n\n≥ 0.196.0.\n\nEverything on this page ships as one pack, both tiers, tested end to end — the\n\nschema, the seed endpoint, the single-token tools, the tenant-scoped tools, the\n\ndiscovery gate, the token lifecycle routes, and the OIDC variant. Fork it, set\n\ntwo variables, deploy.\n\nIf you only want steps 1 through 8 — one token, your own database, no tenancy —\n\ntake **MCP Quickstart** instead. It's the same idea stripped to two tools over\n\none table, with discovery already gated. Start there and move up when you have\n\ncustomers; the config shape doesn't change.\n\nYou can absolutely hand-roll all of this with the TypeScript SDK instead. You'll\n\nalso be hand-rolling the auth, the tenant scoping, the discovery gate, the\n\nrevocation denylist, the traces, and a parallel REST API for the clients that\n\ndon't speak MCP. That's the trade.\n\n`exp`\n\nshort and rely on the denylist for revocation.`multi: true`\n\nfor a\nmulti-statement DDL block (engine ≥ 0.196.0).`{\"data\":{\"<Action>\":{\"data\": …}}}`\n\naction\ntrace, which is why the curl examples pipe through `jq`\n\n. MCP clients parse the\ntool result for you.Is `tools/list`\n\nopen on your MCP server right now? Worth checking.\n\nTurn your Postgres data into secure MCP tools any AI client (Claude Desktop, Claude Code, Cursor) can call.\n\nThe smallest useful MCP server: two tools over one Postgres table, guarded by a single shared token, in one config file. Point Claude Desktop, Claude Code, Cursor or any MCP client at your database with no SDK, no Node project and nothing to host. An Air Pipe interface is an HTTP route; add an mcp block and the same interface is also an MCP tool, secured by the same in-config token check. Tool discovery (tools/list) is gated by that same token via list_authorizer, so an unauthenticated client cannot even enumerate your tools or their input schemas. Includes a seed endpoint that creates the table and sample data in one curl.", "url": "https://wpnews.pro/news/how-to-build-your-own-mcp-server", "canonical_source": "https://dev.to/airpipe/how-to-build-your-own-mcp-server-11gb", "published_at": "2026-08-04 17:05:00+00:00", "updated_at": "2026-08-04 17:49:08.516407+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents"], "entities": ["Air Pipe", "Postgres", "Claude Desktop", "Claude Code", "Cursor", "Neon", "Supabase"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-your-own-mcp-server", "markdown": "https://wpnews.pro/news/how-to-build-your-own-mcp-server.md", "text": "https://wpnews.pro/news/how-to-build-your-own-mcp-server.txt", "jsonld": "https://wpnews.pro/news/how-to-build-your-own-mcp-server.jsonld"}}