🪟 Windows TippsAndroid 17: Neue Version ist hier – Das ist alles neu(16.09.2026 um 11:40 Uhr)
🕵️ Hacking12 Best CASB Solutions Compared (2026): Features & Pricing(16.09.2026 um 09:31 Uhr)
🕵️ Hacking12 Best CIEM Tools Compared (2026): Features & Pricing(16.09.2026 um 09:37 Uhr)
🪟 Windows TippsAndroid 17: Neue Version ist hier – Das ist alles neu(16.09.2026 um 11:40 Uhr)
🕵️ Hacking12 Best CASB Solutions Compared (2026): Features & Pricing(16.09.2026 um 09:31 Uhr)
🕵️ Hacking12 Best CIEM Tools Compared (2026): Features & Pricing(16.09.2026 um 09:37 Uhr)

🔧 Programmierung 🕛 vor 1 Monat 18 Min Lesezeit
0

How to build your own MCP server

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

Most MCP tutorials hand you a Node project. You install an SDK, write a tool

handler, wire up stdio, and end up with something that runs on your laptop as

you, with your credentials, for exactly one user.



That's fine for a demo. It's not something you can give a customer.



Here's the other way, end to end: a database, one config file, a token, and a

URL you paste into Claude. Every step below is a real command against a real

pack — nothing elided, nothing left as an exercise.



Time: about 15 minutes. You'll need: an
Serverless Postgres, free tier, connection string in the dashboard


: algorithm HS256, secret = your

SOLO_SECRET, payload:




CODE
{ "sub": "me", "exp": 1798761600 }






Copy the token. Rotating SOLO_SECRET invalidates it.



Prefer the command line:




CODE
python3 - <<'PY'
import base64, hmac, hashlib, json, os
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=')
secret = os.environ['SOLO_SECRET'].encode()
msg = b64(json.dumps({"alg":"HS256","typ":"JWT"}).encode()) + b'.' +
\
b64(json.dumps({"sub":"me","exp":1798761600}).encode())
sig = b64(hmac.new(secret, msg, hashlib.sha256).digest())
print((msg + b'.' + sig).decode())
PY









Step 7 — Verify it before you touch the client



Debugging through an MCP client is miserable — a failure shows up as "the tool

didn't work." Check with curl first. MCP is JSON-RPC over HTTP, so you can

drive it directly:




CODE
BASE=https://your-airpipe-host/<org>/<env>   # self-hosted: no /<org>/<env>
TOKEN=<the token from step 6>

# List the tools
curl -sX POST $BASE/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
# → "list_tasks"
# → "create_task"

# Call one
curl -sX POST $BASE/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"create_task","arguments":{"title":"Draft the changelog"}}}'


# The same tool over plain HTTP — note the header name changes
curl -sX POST $BASE/solo/tasks \
-H "airpipe-jwt: $TOKEN" \
-H 'content-type: application/json' \
-d '{"status":"open"}' | jq '.data.ListTasks.data'






If tools/list returns your two tools and tools/call returns a row, you're

done — everything after this is client configuration.



Two failures worth naming, because they're the common ones:





  • 401 Invalid or missing token — the secret used to sign doesn't match
    SOLO_SECRET, or exp is in the past. Decode the token at jwt.io and check
    the expiry first; it's usually that.


  • A database error on the query action — the connection string can't be
    reached from your Air Pipe instance. localhost is the usual culprit, SSL
    the other.





Step 8 — Point Claude at it





CODE
{
"mcpServers": {
"my-tasks": {
"url": "https://your-airpipe-host/<org>/<env>/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}





Claude Desktop keeps this at

~/Library/Application Support/Claude/claude_desktop_config.json on macOS and

%APPDATA%\Claude\claude_desktop_config.json on Windows. Claude Code:

claude mcp add --transport http my-tasks https://your-airpipe-host/<org>/<env>/mcp --header "Authorization: Bearer <token>".



Restart the client. Ask "what's on my task list?" and it queries your

database.



You also have, from that same file and with no extra work: an HTTP endpoint for

the clients that don't speak MCP, OpenAPI docs, Prometheus metrics, and an

OpenTelemetry trace for every tool call showing which action ran and how long

the query took. That last one matters more than it sounds — when a model calls

a tool and gets a confusing answer, the trace is how you find out whether the

tool was wrong or the model was.





Give the server a name, not just tools



Every client calls initialize before it lists anything, and that response is

where the server says who it is. Skip it and yours introduces itself with a

built-in name and no description — a listing that's a bare label above a wall of

tool descriptions. That's what mcp_servers at the top of the config fixes:




CODE
mcp_servers:
tasks:
title: Tasks # -> serverInfo.title, the name in the client's UI
instructions: >- # -> the initialize result's `instructions`
A task list backed by Postgres. Use list_tasks to read tasks and
create_task to add one. Both require the operator's bearer token.
default: true # adopt every tool that names no server






instructions matters more than it looks. MCP registries — mcp.so, Glama,

Smithery, PulseMCP — read a remote server's listing description straight off

that field. There is no other place to write one, so an unlisted description

isn't a blank field somewhere; it's a listing nobody clicks.



Check it the same way you checked the tools:




CODE
curl -sX POST $BASE/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl","version":"1"}}}'
\
| jq '{title: .result.serverInfo.title, instructions: .result.instructions}'






Declaring a server and contributing tools to it are separate on purpose. An

MCP server is a named group of tools, not a property of one config: tools in

any of your configs join a server by id, so one identity can cover tools spread

across many files — which also means the declaration can live alone in its own

config (interfaces: {}) and survive whichever tool file you rename next.



The id is a route segment, so a second declaration is a second endpoint from the

same deployment — a public server and an internal one, say:




CODE
mcp_servers:
tasks: # served at /mcp
title: Tasks
default: true
tasks-admin: # served at /mcp/tasks-admin
title: Tasks (admin)









CODE
    mcp:
enabled: true
tool_name: purge_tasks
server: tasks-admin # published only on the named endpoint






Ids are 1–64 characters of a-z, 0-9 or -, only one server may be the

default, and a tool naming a server nothing declares publishes on no server

rather than the wrong one. Needs engine ≥ 1.38.0.





The hole most MCP servers have



Worth knowing regardless of what you build with: tools/call runs your code,

tools/list doesn't.



Listing tools returns metadata — names, descriptions, input schemas. Whatever

auth you put inside your handlers never fires for discovery. So a server with

locked-down calls can still let anyone who knows the URL enumerate every tool

you expose and its full schema. They can't call anything. They can read the map.



For a personal server, fine. For an endpoint you offer customers, that catalog

is often the sensitive part — your tool names are a description of your product.



Close it by adding one line per tool, pointing at an interface that re-runs the

token check when a client lists tools:




CODE
    mcp:
enabled: true
tool_name: list_tasks
list_authorizer: authorize-discovery






And the gate itself — an ordinary interface, not a tool:




CODE
  authorize-discovery:
output: http
method: POST
summary: Authorize MCP tool discovery for the caller's token.
tags: [internal]

actions:
- name: ValidateToken
input: a|headers|
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: "Invalid or missing token"
tests:
- value: airpipe-jwt
is_not_null: true
is_valid_jwt: a|ap_var::SOLO_SECRET|
response_on_success:
http_code: 200






Now an unauthenticated tools/list returns {"result":{"tools":[]}} — not even

the names.




response_on_success: { http_code: 200 } is required. The gate is

fail-closed on anything that isn't an explicit 2xx, and an interface whose

actions all succeed leaves the status code unset — which reads as "not

authorized" and hides every gated tool even for a valid token. If your tools

vanish after adding the gate, this is why.




Needs engine ≥ 1.7.0. Drop the list_authorizer: line to make discovery public.





Now the part that matters: your customers



Everything above is one token, one grant — everyone who holds it sees every row.

Right for pointing an AI at your own database. Useless the moment you have

users.



The multi-tenant shape is the same config with the token doing more work. Your

backend already knows who's logged in, so it mints a per-user token carrying a

tenant_id:




CODE
TOKEN=$(curl -sX POST $BASE/auth/exchange \
-H "x-exchange-secret: $EXCHANGE_SECRET" \
-H 'content-type: application/json' \
-d '{"tenant_id":"11111111-1111-1111-1111-111111111111",
"subject":"user-123","name":"laptop"}'
\
| jq -r '.data.Result.data.token')






Then every query scopes to the claim in that token instead of a hardcoded id:




CODE
      - name: ListTasks
database: main
query: |
SELECT id, title, status, created_at
FROM mcp_tasks
WHERE tenant_id = $1::uuid
AND ($2::text IS NULL OR status = $2::text)
ORDER BY created_at DESC
LIMIT 200;
params:
- a|ValidateJwt::tenant_id|
- a|body::status->default(null)|






A row from another tenant doesn't match. Cross-tenant access is structurally

impossible rather than merely forbidden — there's no code path where forgetting

a WHERE clause leaks a customer's data, because the filter is the query.

One endpoint, every customer, each seeing only their own rows.





Revocation, which stateless JWTs can't do alone



A signature check can't tell a revoked token from a valid one — that's what the

mcp_tokens table is for. Every tool re-checks the token's jti against it:




CODE
      - name: CheckTokenActive
run_when_succeeded:
actions: [ValidateJwt]
http_code_on_error: 401
database: main
hide_data_on_success: true
query: |
SELECT (
$1::uuid IS NULL OR EXISTS (
SELECT 1 FROM mcp_tokens
WHERE jti = $1::uuid AND revoked_at IS NULL AND expires_at > NOW()
)
) AS ok;
params:
- a|ValidateJwt::jti->default(null)|
assert:
http_code_on_error: 401
error_message: "Token revoked or expired"
tests:
- value: "[0]ok"
is_equal_to: true






Revoking is a call, not an SSH session:




CODE
curl -sX POST $BASE/auth/revoke \
-H "x-exchange-secret: $EXCHANGE_SECRET" \
-H 'content-type: application/json' \
-d '{"jti":"<the jti returned at mint time>"}'






The next call is refused: 401 Token revoked or expired on the HTTP route, and

an error result from the tool over MCP. This is the piece a naive JWT setup

forgets.





Already using Auth0, Clerk or Cognito?



Skip the exchange hop entirely. Point is_valid_jwt at your provider's JWKS and

verify their RS256 tokens directly:




CODE
            - value: airpipe-jwt
is_not_null: true
is_valid_jwt:
jwks_url: a|ap_var::OIDC_JWKS_URL|
alg: RS256
iss: a|ap_var::OIDC_ISSUER|
aud: a|ap_var::OIDC_AUDIENCE|






Air Pipe fetches and caches the keys, selects the signer by the token's kid,

and enforces iss / aud / exp. Provider key rotation just works. Add a

tenant_id claim in your IdP and the scoping above is unchanged. Needs engine

≥ 0.196.0.






The shortcut



Everything on this page ships as one pack, both tiers, tested end to end — the

schema, the seed endpoint, the single-token tools, the tenant-scoped tools, the

discovery gate, the token lifecycle routes, and the OIDC variant. Fork it, set

two variables, deploy.



If you only want steps 1 through 8 — one token, your own database, no tenancy —

take MCP Quickstart instead. It's the same idea stripped to two tools over

one table, with discovery already gated. Start there and move up when you have

customers; the config shape doesn't change.



You can absolutely hand-roll all of this with the TypeScript SDK instead. You'll

also be hand-rolling the auth, the tenant scoping, the discovery gate, the

revocation denylist, the traces, and a parallel REST API for the clients that

don't speak MCP. That's the trade.






Known limits, so you're not surprised





  • Tokens are long-lived bearers. MCP clients today authenticate with a
    static bearer pasted into config — there's no interactive OAuth flow yet. Keep
    exp short and rely on the denylist for revocation.


  • One statement per action on Postgres — the driver prepares the query, and
    a prepared statement holds one command. Use multi: true for a
    multi-statement DDL block (engine ≥ 0.196.0).


  • HTTP responses are wrapped in a {"data":{"<Action>":{"data": …}}} action
    trace, which is why the curl examples pipe through jq. MCP clients parse the
    tool result for you.



Is tools/list open on your MCP server right now? Worth checking.






Skip the setup







The 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.

Vollständiger Original-Artikel
Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
2 Quellen
CVE-2026-88255 | ZenHive mpp up to 0.16.1 Duplicate Submission Gate lib/mpp/replay.ex reserve_hash_atomic input validation (EUVD-2026-80256)
1 Quelle
Android 17: Neue Version ist hier – Das ist alles neu
1 Quelle
Die entscheidende Hürde: Xpeng will deutsch und nicht chinesisch sein