> Source: https://wexa.ai/docs/surfaces/mcp/custom-clients

# Custom clients

This page is the contract. If you are writing a client rather than configuring one, everything you
need is here: the two discovery documents, dynamic registration, the proof-key exchange, the session
rules that hand-rolled clients most often get wrong, and both transports.

Hosts are written as `https://fabric.wexa.ai` throughout; substitute your own deployment's issuer,
which `GET /v1/connection-info` will tell you without a credential.

## Start from the challenge

A client that has only a URL should not guess where the authorization server is. Call the endpoint
without a credential and read the answer:

```bash
curl -sS -D - -X POST https://fabric.wexa.ai/mcp/proj_abc123 \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
```

```text
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="https://fabric.wexa.ai/.well-known/oauth-protected-resource/mcp/proj_abc123"

{"error":"invalid_token","error_description":"missing bearer credential"}
```

The `resource_metadata` parameter is the whole starting point. Note what it points at: not the
generic discovery document, but a **project-scoped** one whose path repeats the project you tried to
connect to. That is deliberate, and the next section explains what it buys you.

## The two discovery documents

Wexa publishes protected-resource metadata at two paths. They differ in exactly one field, and
that field is the point.

The generic document, at `/.well-known/oauth-protected-resource`:

```json
{
  "resource": "https://fabric.wexa.ai",
  "authorization_servers": ["https://fabric.wexa.ai"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": [
    "fabric:query.read", "fabric:ontology.write", "fabric:docs.read", "fabric:codesync.write",
    "fabric:agent.run", "fabric:orchestrate.read", "fabric:orchestrate.write"
  ]
}
```

The project-scoped variant, at `/.well-known/oauth-protected-resource/mcp/{projectId}`:

```json
{
  "resource": "https://fabric.wexa.ai/mcp/proj_abc123",
  "authorization_servers": ["https://fabric.wexa.ai"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": [
    "fabric:query.read", "fabric:ontology.write", "fabric:docs.read", "fabric:codesync.write",
    "fabric:agent.run", "fabric:orchestrate.read", "fabric:orchestrate.write"
  ]
}
```

`resource` names the exact URL you connected to. Send that value back as the `resource` parameter on
the authorization request and Wexa extracts the project from it, locks the consent screen to that
project, and does not offer a picker. Omit it and the reader chooses a project by hand, which is how
a connection made to one project ends up authorized against another. **Use the project-scoped
document.** It is the mechanism by which the project in your URL survives the round trip through the
browser.

`authorization_servers` points at the authorization server metadata, which is a single document
whatever route you arrived by:

```bash
curl -sS https://fabric.wexa.ai/.well-known/oauth-authorization-server
```

```json
{
  "issuer": "https://fabric.wexa.ai",
  "authorization_endpoint": "https://fabric.wexa.ai/oauth/authorize",
  "token_endpoint": "https://fabric.wexa.ai/oauth/token",
  "registration_endpoint": "https://fabric.wexa.ai/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"]
}
```

Three things to read off it. Only the authorization-code and refresh-token grants exist — there is
no client-credentials grant, so a machine-to-machine caller with no person behind it uses an API key
instead. `S256` is the only proof-key method, and `plain` is not offered. And `none` is an accepted
client authentication method, so a public client with no secret is a first-class citizen.

## Register dynamically

There is no developer portal. A client mints its own identifiers, unauthenticated, at the
registration endpoint the metadata named:

```bash
curl -sS -X POST https://fabric.wexa.ai/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{"client_name":"my-client","redirect_uris":["http://127.0.0.1:9876/callback"]}'
```

```json
{
  "client_id": "fab_client_Z6R3UrscnP5HLdkFA0MB4NV3",
  "client_secret": "fab_secret_4UCnz3b76ess8OjyC1qOg1qg",
  "client_name": "my-client",
  "redirect_uris": ["http://127.0.0.1:9876/callback"]
}
```

The response is `201 Created`. A secret is always issued, but a public client can ignore it and
authenticate with `none`; the proof key is what actually binds the exchange. Redirect URIs are
matched exactly at authorization time, so register every one you will use, loopback ports included.

## Proof key for code exchange

The challenge is required. There is no path through authorization without one, and the refusal is
explicit:

```json
{ "error": "invalid_request", "error_description": "code_challenge (PKCE S256) is required" }
```

Generate a high-entropy verifier, and derive the challenge as the base64url encoding, without
padding, of its SHA-256 digest:

```python
import base64, hashlib, secrets

verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
```

Keep the verifier in memory for the length of the flow. Send only the challenge to the authorization
endpoint, and only the verifier to the token endpoint.

## Authorize

Open the authorization endpoint in the reader's browser, carrying the challenge, your state, the
grants you want, and the `resource` value from the project-scoped discovery document:

```text
https://fabric.wexa.ai/oauth/authorize
  ?response_type=code
  &client_id=fab_client_Z6R3UrscnP5HLdkFA0MB4NV3
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Fcallback
  &state=<random, checked on return>
  &code_challenge=<challenge>
  &code_challenge_method=S256
  &scope=fabric%3Aquery.read%20fabric%3Adocs.read
  &resource=https%3A%2F%2Ffabric.wexa.ai%2Fmcp%2Fproj_abc123
```

Wexa serves the consent screen, where the reader signs in and confirms the project. Membership is re-checked before anything is issued, so consenting to a project you
are not a member of fails at this step rather than producing a token that cannot read anything.

Approval redirects to your `redirect_uri`:

```text
HTTP/1.1 302 Found
Location: http://127.0.0.1:9876/callback?code=fab_code_TEkG5ezViNc7FQSXSa-7vz29&state=xyz
```

Check `state` against what you sent. The code is valid for five minutes and is single-use.

## Exchange the code

```bash
curl -sS -X POST https://fabric.wexa.ai/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code' \
  -d 'code=fab_code_TEkG5ezViNc7FQSXSa-7vz29' \
  -d 'redirect_uri=http://127.0.0.1:9876/callback' \
  -d 'client_id=fab_client_Z6R3UrscnP5HLdkFA0MB4NV3' \
  -d 'code_verifier=<verifier>'
```

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "fab_rt_ojh07nmHYTPAtCQwn...",
  "scope": "fabric:query.read fabric:docs.read"
}
```

A verifier that does not match the challenge is refused with `invalid_grant` and the code is burned,
so a mismatch means restarting the flow rather than retrying the exchange:

```json
{ "error": "invalid_grant", "error_description": "invalid or expired code" }
```

Access tokens last one hour. Refresh tokens last thirty days.

## Refresh, and the rotation rule

```bash
curl -sS -X POST https://fabric.wexa.ai/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=refresh_token' \
  -d 'refresh_token=fab_rt_ojh07nmHYTPAtCQwn...' \
  -d 'client_id=fab_client_Z6R3UrscnP5HLdkFA0MB4NV3'
```

The response carries a **new** refresh token, and the one you presented is revoked immediately:

```json
{ "error": "invalid_grant", "error_description": "invalid refresh token" }
```

That is what replaying the old one gets you. Store the new refresh token before you use the new
access token, and never refresh from two places at once — a second, concurrent refresh in another
process logs the reader out, because the first one already rotated the token away.

## Or skip all of it with an API key

A client with no person in front of it — a scheduled job, a backend service — sends a `fab_sk_…` key
as a bearer token and uses none of the above. The key is minted by a project administrator, is
scoped to one project, and is shown once:

```bash
curl -sS -X POST https://fabric.wexa.ai/v1/apikeys \
  -H "Authorization: Bearer $FABRIC_USER_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"org_id":"org_...","dept_id":"dept_...","project_id":"proj_...","name":"my-client"}'
```

A key inherits the grants of the role that minted it. An owner's key comes back carrying eight,
including `fabric:ontology.write` and `fabric:agent.run` — so a key is not automatically a read-only
credential, and a client that only needs to read should be given one minted from a role that only
reads.

## Streamable HTTP, in full

`POST /mcp/{projectId}`. Send `Authorization`, `Content-Type: application/json`, and an `Accept`
header naming the framings you handle.

```bash
curl -sS -D - -X POST https://fabric.wexa.ai/mcp/proj_abc123 \
  -H "Authorization: Bearer $FABRIC_API_KEY" \
  -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":"my-client","version":"1.0"}}}'
```

```text
HTTP/1.1 200 OK
Content-Type: text/event-stream
Mcp-Protocol-Version: 2025-06-18
Mcp-Session-Id: sess_9d07f896af040e21edb36404903783ac

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{"listChanged":false}},"protocolVersion":"2025-06-18","serverInfo":{"name":"wexa-fabric","version":"0.1.0"}}}
```

**Framing follows your `Accept` header.** Accept `text/event-stream` and each response arrives as a
one-shot `event: message`; accept only `application/json` and the same response arrives as a plain
JSON body with `Content-Type: application/json`. Both are correct and both are supported, so pick
whichever your parser prefers and be consistent.

**Version negotiation echoes what you asked for**, when it is one Wexa speaks: `2024-11-05`,
`2025-03-26` and `2025-06-18`. Ask for any of the three and you get it back, in the response body
and in the `MCP-Protocol-Version` response header. Ask for something else and the negotiation falls
back to `2025-06-18` rather than failing. An `MCP-Protocol-Version` **request** header, if you send
one, overrides the version in the body.

Then the ordinary traffic. `notifications/initialized` is a notification and is answered with
`202 Accepted` and an empty body — do not wait for a JSON-RPC response to it:

```bash
curl -sS -X POST https://fabric.wexa.ai/mcp/proj_abc123 \
  -H "Authorization: Bearer $FABRIC_API_KEY" \
  -H 'Mcp-Session-Id: sess_9d07f896af040e21edb36404903783ac' \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```

A call, and the envelope it answers in:

```bash
curl -sS -X POST https://fabric.wexa.ai/mcp/proj_abc123 \
  -H "Authorization: Bearer $FABRIC_API_KEY" \
  -H 'Mcp-Session-Id: sess_9d07f896af040e21edb36404903783ac' \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"docs","arguments":{"topic":"governance"}}}'
```

The tool's real answer is JSON **serialized into the text** of the first content block, because the
protocol carries text. Unwrap it and you have the same `lifecycle_id` and `result` the REST API
would have returned. Finally, `DELETE` on the same URL with the session header tears the session
down and answers `204 No Content`.

## Errors: three layers, and they mean different things

A client that flattens these into one "it failed" will retry things it must not.

**HTTP status** — the request never reached a tool. `401` means the credential is missing, wrong or
revoked, and the `WWW-Authenticate` header tells you where to re-authorize. `403` means the
credential is for a different project than the URL names. `410` means the path is retired. `404`
after a valid start means the session is gone.

**A JSON-RPC `error`** — the message was malformed or the method does not exist. `-32600` for a
missing session header, `-32601` for an unknown method, `-32602` for an unknown tool or unparseable
parameters:

```json
{ "jsonrpc": "2.0", "id": 4, "error": { "code": -32602, "message": "unknown tool \"no-such-tool\"" } }
```

**A result with `isError: true`** — the tool ran and refused, which is the layer most refusals
arrive at. The text names the stage, so the reason is machine-readable enough to act on:

```json
{ "content": [ { "type": "text", "text": "error (S4:validate-input): cypher rejected: query must start with a read clause (MATCH/OPTIONAL MATCH/WITH/UNWIND/RETURN/CALL), got \"CREATE\"" } ], "isError": true }
```

```json
{ "content": [ { "type": "text", "text": "error (S2:resolve-scope): token missing required grant \"fabric:ontology.write\"" } ], "isError": true }
```

Surface these to the model rather than swallowing them. `isError: true` is how the protocol lets a
model read a refusal and correct itself, and Wexa's messages are written to be corrected from.
A call held for human approval arrives here too, as `isError: false` with a `status` of
`pending_approval` — see [policy decisions and approvals](/docs/concepts/policy-and-approvals).

## The SSE transport

Implement this only if you cannot do the above. Open the stream, and read the message endpoint out
of its first event:

```bash
curl -sS -N https://fabric.wexa.ai/sse/proj_abc123 \
  -H "Authorization: Bearer $FABRIC_API_KEY"
```

```text
event: endpoint
data: /messages?session=sse_000001
```

Then post each JSON-RPC message to that endpoint. Each post is answered `202 Accepted` with an empty
body, and the actual reply appears on the stream you are still holding open:

```text
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{"listChanged":false}},...}}
```

Three things to know before you commit to it. A reply is pushed onto a bounded buffer and
**dropped** if you are not reading fast enough, so a slow consumer loses answers with no error.
`POST /messages` has no project in its path, so the wrong-URL check that guards `/mcp/{projectId}`
does not apply — your credential's own scope is still the real boundary, but you lose that warning.
And the session dies with the stream, so a dropped connection means reconnecting and re-initializing.

## Do not build against these

The bare `/mcp` and `/sse` paths, with no project, are permanently retired. `POST`, `GET` and
`DELETE /mcp` and `GET /sse` all answer:

```text
HTTP/1.1 410 Gone

{"error":"endpoint_moved","error_description":"This URL no longer works. Each project now has its own MCP connect URL — open the dashboard's Connect page for this project and reconnect using that URL."}
```

The answer is the same with a valid credential as without one, because the handler runs before
authentication — so a client that treats `410` as an authorization failure and re-runs the whole
flow will get there again. Treat it as terminal: fix the URL, do not retry, and do not fall back.

One trap in the discovery document: `GET /v1/connection-info` currently reports `mcp.sse_url` as the
bare `https://fabric.wexa.ai/sse`, which is one of these retired paths. `mcp.url_template` is
correct and carries `{projectId}`. Build from the template, and construct the SSE URL yourself by
substituting `/sse/` for `/mcp/`.

## Where to go next

**Reference — All tools**
Every tool's arguments, returns and refusals, with the same call on four surfaces.

**Concepts — Scope and server-bound arguments**
The arguments a client may never send, and why passing one is refused.

**Concepts — Quota and credits**
The windows a busy client will hit first, and what exhausting one returns.
