Build an MCP server
The standards, keywords and authentication handling an MCP server you write needs so it plugs into Harnix as a connector.
This page is for teams who write their own MCP server (an internal API, an order system, a CRM…) so agents on Harnix can call it through a connector. Harnix is a remote MCP client: it calls your server over HTTPS on behalf of the organization that added the connector.
What your server needs, in short:
| Area | Standard |
|---|---|
| Protocol | Model Context Protocol, using an official SDK (Python, TypeScript…) |
| Transport | Streamable HTTP at a single URL (e.g. https://mcp.example.com/mcp); legacy SSE is still supported. stdio is not |
| Network | https, a public host name, no redirect on the MCP URL |
| Authentication | One of four: none, a fixed header, OAuth (authorization code + PKCE), OAuth client credentials |
| Tools | snake_case names, a clear description, a JSON Schema inputSchema, readOnlyHint: true on read-only tools |
| Results | text content, at most 64 KB, isError: true for business errors |
Connection requirements
- URL. One MCP endpoint, e.g.
https://mcp.example.com/mcp. It must behttpsand resolve to a public address; private/internal addresses (10.x,192.168.x,localhost…) and URLs with a username or password are refused. Harnix does not follow redirects: a3xxon the MCP URL is an error. - Transport. With Automatic, Harnix tries Streamable HTTP first, then SSE. New servers should use Streamable HTTP.
- Timing. The handshake (
initialize) and the tool list (tools/list) must finish within 10 seconds. A single tool call may run for up to 120 seconds. - Protocol version. Harnix initializes with
protocolVersion2025-06-18; the official SDKs negotiate versions for you. - The tool list is read again whenever a run starts. A tool you add to the server is enabled for every agent using the connector (an admin can switch individual tools off in the console).
Pick an authentication method
| Method (in the console) | Pick it when | Your server must |
|---|---|---|
| No auth | The data is public, or the server only accepts trusted IP ranges | Nothing |
| Headers | An internal API with one key for the whole organization. The fastest route for internal teams | Check a fixed header |
| Client credentials | Server-to-server through an existing identity provider (Keycloak, Auth0, Entra ID…), nobody signs in | Be an OAuth resource server: discovery + access-token checks |
| Automatic → OAuth | Tools must act with a user account's permissions (Google Drive, a per-user CRM…) | Be a full OAuth resource server per MCP Authorization |
A header-protected server needs Headers, not Automatic
With Automatic, Harnix sends initialize with nothing attached; if the
server answers 401, Harnix takes that to mean OAuth and looks for the
discovery documents. A server that only checks an API key has none, and the
connector reports “Couldn't read this server's connection settings.”
(discovery_failed).
Fixed header
The admin enters a header name and value in the connector sheet, e.g.
Authorization: Bearer <key> or X-API-Key: <key>. Harnix sends that header
on every request to the MCP URL. The value is encrypted at rest and never
shown again.
On your server:
- Refuse a request with a missing or wrong header with
401(or403). - Compare keys in constant time (
hmac.compare_digest,crypto.timingSafeEqual) and never log the header value. - To rotate: accept both the old and the new key for a while, update the header in the console, then drop the old key.
Every conversation through this connector uses the same key — if departments need different access, create several connectors with different keys.
OAuth: Harnix signs in on a user's behalf
This is the MCP Authorization flow: OAuth 2.1, authorization code + PKCE. Your MCP server is the resource server; tokens are issued by an authorization server (the same server, or your Keycloak/Auth0/Entra ID…).
Standards Harnix uses:
| Standard | Purpose | Required? |
|---|---|---|
| RFC 9728 — OAuth Protected Resource Metadata | The MCP server names its authorization server | Yes |
| RFC 8414 — Authorization Server Metadata (or OpenID Connect Discovery) | Harnix reads endpoints and supported methods | Yes |
RFC 7636 — PKCE, method S256 | Protects the code | Yes |
RFC 8707 — Resource Indicators (resource) | Tokens are issued for your MCP server specifically | Yes (Harnix always sends it) |
| RFC 7591 — Dynamic Client Registration | Harnix registers its own client | No — without it the admin enters a Client ID |
| RFC 7009 — Token Revocation | Tokens are revoked when an admin clicks Disconnect | No |
RFC 9207 — iss on the redirect | Guards against an authorization-server mix-up | No — checked when present |
Step 1 — Answer 401 with WWW-Authenticate
A request to the MCP URL with no token (or a wrong or expired one) gets:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="orders.read"resource_metadata— the URL of the Protected Resource Metadata document. Without it, Harnix trieshttps://mcp.example.com/.well-known/oauth-protected-resource/mcp(after the MCP URL's path), then/.well-known/oauth-protected-resource.scope(optional) — the scope to ask for.
Step 2 — Protected Resource Metadata
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["orders.read", "orders.write"]
}resourceis the MCP URL itself, as the admin enters it in Harnix.- Harnix uses the first entry of
authorization_servers.
Step 3 — Authorization Server Metadata
Harnix reads https://auth.example.com/.well-known/oauth-authorization-server
(with the issuer's path appended, if any), then
/.well-known/openid-configuration.
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"revocation_endpoint": "https://auth.example.com/revoke",
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
"grant_types_supported": ["authorization_code", "refresh_token"]
}issuermust match the authorization server URL from step 2.code_challenge_methods_supportedmust includeS256, or the connector reportspkce_unsupported.- Every endpoint must be public
https— the same rule as the MCP URL. - Harnix caches this document for up to 24 hours.
Step 4 — Client registration
With a registration_endpoint, Harnix registers itself (RFC 7591) with
{
"client_name": "Harnix",
"redirect_uris": ["https://app.harnix.vn/connectors/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}token_endpoint_auth_method is none (a public client) when the server
supports it, client_secret_basic otherwise.
Without a registration_endpoint: create a client on the authorization
server with redirect URI https://app.harnix.vn/connectors/callback and the
authorization_code + refresh_token grants, then give the admin the
OAuth Client ID (and the OAuth Client Secret for a confidential
client) to enter under Advanced. With neither, the connector reports
registration_unsupported.
Step 5 — Sign-in and code exchange
The admin clicks Connect; Harnix opens the authorization_endpoint with:
| Parameter | Value |
|---|---|
response_type | code |
client_id | the registered client |
redirect_uri | https://app.harnix.vn/connectors/callback |
state | a random string, returned unchanged |
code_challenge, code_challenge_method | PKCE, S256 |
resource | the MCP URL |
scope | the admin's configured scopes; otherwise the step 1 scope; otherwise every scopes_supported |
Once the user approves, redirect back to redirect_uri with code and
state (plus iss if you support RFC 9207). A sign-in attempt is good for
10 minutes. Harnix then calls the token_endpoint
(application/x-www-form-urlencoded):
POST /token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret) ← only with a secret
grant_type=authorization_code&code=…&redirect_uri=https://app.harnix.vn/connectors/callback
&code_verifier=…&resource=https://mcp.example.com/mcp&client_id=…With a secret, Harnix uses client_secret_basic when the metadata lists it
(or says nothing), client_secret_post otherwise. A public client sends only
client_id.
Token response:
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…",
"scope": "orders.read"
}Return expires_in and a refresh_token. Without a refresh token, the admin
must click Reconnect whenever the token expires.
Step 6 — Check the token on every MCP request
Harnix sends Authorization: Bearer <access_token> on every request. Your
MCP server:
- Validates the token (JWT signature against the JWKS, or token introspection).
- Checks expiry.
- Checks the audience: the token must have been issued for your own MCP
URL (the
resourcevalue). Never accept a token issued for another service. - Checks scopes per tool.
| Situation | Answer | What Harnix does |
|---|---|---|
| Missing, wrong or expired token | 401 + WWW-Authenticate as in step 1 | Refreshes once and retries; still 401 → Needs reconnect |
| Valid token, not enough permission | 403 + WWW-Authenticate: Bearer error="insufficient_scope", scope="orders.write" | Needs reconnect; the next sign-in asks for that scope too |
Refresh and revocation
- Harnix refreshes 60 seconds before the token expires
(
grant_type=refresh_token, withresource), or right after a401. - Refresh-token rotation (a new refresh token on every refresh) is supported; Harnix never uses one refresh token twice in parallel.
- A
4xxfrom the authorization server on refresh (e.g.invalid_grant) moves the connector to Needs reconnect. A5xxor429is treated as transient; Harnix tries again on the next run. - When an admin clicks Disconnect, Harnix calls the
revocation_endpoint(if any) for the refresh token, then the access token.
Client credentials
For when nobody signs in: Harnix fetches tokens itself with the Client ID and
Client secret the admin enters in the console. Your server still does
steps 1–3 above (401 + Protected Resource Metadata + Authorization
Server Metadata, and the metadata must still list S256) and step 6
(token checks). Harnix calls:
POST /token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials&resource=https://mcp.example.com/mcp&scope=orders.read&client_id=…There is no refresh token: when the token expires or gets a 401, Harnix
asks for a new one. A 4xx from the authorization server puts the connector
in Error (client_credentials_rejected) — the admin checks the Client ID
and secret.
Designing tools
The model reads a tool's name, description and schema to decide which tool to call with which arguments. A well-written tool matters as much as code that runs correctly.
-
Name:
snake_case, ASCII, verb + noun (get_order_status,create_ticket). The model seesmcp_<connector name>_<tool name>, cut at 64 characters — keep tool names short. -
description: what the tool does, when to use it, its limits. Write it for the model, not for a person. -
inputSchema: a complete JSON Schema — types,required,enum, a description per field. Validate arguments on the server; never trust the model. -
Annotations — decide whether the end-user is asked before the tool runs (see Confirmation before a tool call):
Annotation Harnix readOnlyHint: trueRuns right away Absent, or readOnlyHint: falseAsks the end-user first Only mark
readOnlyHint: truewhen the tool truly changes nothing. -
title(optional): a readable name, shown in the console.
Returning results
- Return
textcontent. Harnix reads onlytextblocks and joins them; images and binary resources are ignored. Return structured data as JSON text. - A result over 64 KB is cut, with a note. Page or summarize instead of returning a whole table.
- Business errors (“order A-1003 not found”) → a result with
isError: trueand one explanatory sentence; the model reads it and answers the user. Don't throw for business errors. - Never put secrets, tokens or stack traces in a result: it goes straight into the prompt and shows up on the trace.
- Idempotency: if a run is interrupted while a tool is running, Harnix may call that tool once more. Tools that write should guard against duplicates (e.g. take a client-generated order id, or check before creating).
Examples
# pip install "mcp[cli]"
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
mcp = FastMCP("orders", stateless_http=True)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def get_order_status(order_id: str) -> str:
"""Delivery status of one order. Use when a customer asks where an order is."""
return f"Order {order_id} was handed to DHL on 2026-10-10."
@mcp.tool() # no readOnlyHint → Harnix asks the end-user first
def create_order(sku: str, quantity: int) -> str:
"""Place a new order for a SKU."""
return f"Order placed: {quantity} x {sku}."
if __name__ == "__main__":
mcp.run(transport="streamable-http") # served at /mcpThe OAuth side (steps 1–6) is usually handled by an existing authorization server; the official SDKs ship token verification and Protected Resource Metadata for resource servers — see your SDK's documentation.
Check before you hand it over
-
Run the MCP Inspector (
npx @modelcontextprotocol/inspector) against your URL: it connects, lists every tool, and each tool runs. -
For OAuth, check discovery with
curl:curl -i -X POST https://mcp.example.com/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' # → 401 with resource_metadata in WWW-Authenticate curl https://mcp.example.com/.well-known/oauth-protected-resource/mcp curl https://auth.example.com/.well-known/oauth-authorization-server # → code_challenge_methods_supported includes "S256" -
Add the connector in the console with the right Authentication, click Test: you should see Found N tools.
-
Assign it to a test agent, ask a question that needs a tool, and open Runs & traces to see the arguments and the result.
Error codes and server-side fixes
| Code (the console shows a matching sentence) | Usual cause | Fix |
|---|---|---|
discovery_failed | No Protected Resource Metadata or Authorization Server Metadata; issuer mismatch; a header-protected server with the connector left on Automatic | Implement steps 1–3, or pick Headers |
pkce_unsupported | The metadata doesn't list S256 | Enable PKCE S256 on the authorization server |
registration_unsupported | No registration_endpoint and no Client ID entered | Enable Dynamic Client Registration, or create a client by hand and enter it under Advanced |
token_exchange_failed | The token endpoint refused the code — wrong redirect_uri, code_verifier, or client auth | Check the redirect URI https://app.harnix.vn/connectors/callback and the client auth method |
refresh_rejected | The refresh token was revoked or expired, or the server still answers 401 after a refresh | Check token audience and lifetime; the admin clicks Reconnect |
insufficient_scope | The server answered 403 insufficient_scope | Working as intended: the admin Reconnects to grant the extra scope |
client_credentials_rejected | Wrong Client ID/secret, or a freshly issued token still gets 401 | Check the client and the token's audience |
connection timed out | No answer within 10 seconds (connect) / 120 seconds (tool call) | Speed up initialize/tools/list, check the firewall |
An exception class name (e.g. ConnectError, ProbeStatusError) | Unreachable, or an unexpected HTTP status (3xx, 5xx) | Check the URL, the TLS certificate, redirects |
Harnix never shows your server's own error text (it might contain a secret) — only the codes above or an exception class name. For details, read your server's logs.