On this page

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:

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":{}}'
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:

{
  "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}:

{
  "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:

curl -sS https://fabric.wexa.ai/.well-known/oauth-authorization-server
{
  "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:

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"]}'
{
  "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:

{ "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:

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:

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:

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

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>'
{
  "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:

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

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

Refresh, and the rotation rule

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:

{ "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:

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.

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"}}}'
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:

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:

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:

{ "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:

{ "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 }
{ "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.

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:

curl -sS -N https://fabric.wexa.ai/sse/proj_abc123 \
  -H "Authorization: Bearer $FABRIC_API_KEY"
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:

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:

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