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
Every tool's arguments, returns and refusals, with the same call on four surfaces.
The arguments a client may never send, and why passing one is refused.
The windows a busy client will hit first, and what exhausting one returns.