MCP server
CredenShare hosts an MCP server so an AI assistant can do the work around a credential handoff — asking someone for a secret, listing what exists, expiring something, rotating a webhook signing secret — without ever handling a plaintext secret itself.
That constraint shapes every tool on the surface. No tool accepts the contents of a secret as an argument, because that would put the secret in the model's context and in our logs. No tool returns the contents of one, for the same reason in the other direction. And CredenShare cannot decrypt its own storage, so metadata-only is a property of the system here rather than a restriction waiting to be lifted.
The consequence is worth knowing before you start: the most useful tool on this server is the one that hands the last step to a human. See create_share_via_browser below.
Endpoint
POST https://api.credenshare.io/v1/mcp
One URL, one HTTP method. The transport is MCP's Streamable HTTP in stateless mode: one POST carries one JSON-RPC 2.0 request and gets one JSON response back. There is no SSE stream, no session id, and no long-lived connection — every request is complete on its own, which is what lets a serverless function serve it.
Point your MCP client at that URL with a bearer credential. Nothing else is negotiated.
Authentication
The MCP server uses the same API keys as the REST API, in the standard header:
curl -sS https://api.credenshare.io/v1/mcp \
-H "Authorization: Bearer crs_sk_live_<keyId>.<authSecret>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Keys are created from your CredenShare account settings and the secret is shown exactly once. The API cannot mint keys, and your plan must include API access.
crs_sk_live_<keyId>.<authSecret>.<custodySecret>. The third part is the custody secret and must never leave your machine. A credential that arrives with all three parts is refused outright, and the key is then treated as compromised — rotate it.Authentication is settled before the JSON-RPC layer is reached, so a rejected request comes back as an HTTP error whose body is not a JSON-RPC envelope:
| Situation | Response |
|---|---|
No Authorization header | 401 with {"message":"Unauthorized"} |
| A credential that does not verify | 403 with an access-denied message |
A revoked key is deliberately indistinguishable from one that never existed. Nothing in the response can be used to work out which key ids are real.
Scopes
Every tool requires exactly one scope, listed in the tool table below. Scopes are matched as exact strings and there is no hierarchy: shares:write does not imply shares:read. Mint the key with every scope the tools you intend to call require.
A call with a valid credential but a missing scope is not an HTTP error. It comes back as a tool result marked isError, because the call was well-formed and the answer is final:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "This API key lacks the \"shares:write\" scope, which expire_share requires. Mint a key with that scope."
}
],
"isError": true
}
}
Protocol
Four JSON-RPC methods are implemented. Anything else returns -32601.
| Method | Result |
|---|---|
initialize | Server identity and capabilities |
ping | An empty object |
tools/list | {"tools":[…]}, where each entry has name, description and inputSchema |
tools/call | The tool's result |
initialize advertises protocol version 2025-06-18. The value is fixed — the server does not echo the version the client asked for.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "credenshare", "version": "1.0.0" }
}
}
The result also carries an instructions string, which clients that surface it will display. It states up front that no tool returns a secret, so a model does not go looking for one.
Only the tools capability is advertised. There are no resources, no prompts, and deliberately no sampling: a security product asking the client's model to generate something on its behalf has no use here.
A notification — a request with no id — produces no response body at all: HTTP 202 and nothing else. Clients send notifications/initialized on every session, and answering it is a protocol violation that some clients surface to the user as an error.
Errors
| Code | Message | When |
|---|---|---|
-32700 | invalid JSON | The request body could not be parsed. The parser's own message is in data. |
-32600 | not a JSON-RPC 2.0 request | jsonrpc is not "2.0", or method is empty |
-32601 | unknown method: <name> | Not one of the four methods above |
-32603 | could not encode response | The server could not serialise its own response |
Every JSON-RPC error is carried on HTTP 200. A JSON-RPC error says the call was malformed; the HTTP layer still succeeded in carrying that answer. So an HTTP status other than 200 or 202 is a transport or authentication problem, never a protocol one.
Problems with a tool's arguments do not come back as -32602 either. They come back as tool results marked isError. The distinction matters in practice: a business-rule refusal returned as a protocol error looks to a model like a transport fault worth retrying, when it is really a final answer.
Tools
Eight tools, with the scope each one requires:
| Tool | What it does | Arguments | Scope |
|---|---|---|---|
request_secret_from | Creates a secure request and returns a collect link to give to the person who holds the secret | title (string, required), fields (array of string, required), expires_in_days (integer, optional) | requests:write |
create_share_via_browser | Returns a link that opens a pre-composed share for a human to fill in. Creates nothing server-side | title (string, required), fields (array of string, required) | shares:write |
list_shares | Lists the shares on the account, newest first. Metadata only | limit (integer, optional, 1–100, default 25), page (integer, optional, 1-based) | shares:read |
get_share | Looks up one share by short code. Metadata only | short_code (string, required) | shares:read |
expire_share | Expires a share immediately. Irreversible | short_code (string, required) | shares:write |
share_stats | Reports how many shares are active and expired, total views, and a 14-day daily view history. Figures only | None | stats:read |
list_webhooks | Lists the account's webhook endpoints, the events each is subscribed to, and whether it is enabled | None | shares:read |
rotate_webhook_secret | Issues a new signing secret for an endpoint and returns it once | endpoint_id (string, required) | shares:write |
A call looks like this:
curl -sS https://api.credenshare.io/v1/mcp \
-H "Authorization: Bearer crs_sk_live_<keyId>.<authSecret>" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_shares",
"arguments": { "limit": 10 }
}
}'
Results are prose, not JSON
Every tool returns a single block of text:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "3 share(s) total, page 1 of 1:\n\n a1b2c3 Staging deploy credentials (no expiry)\n"
}
]
}
}
That is a deliberate choice, not an oversight. A model reads prose at least as well as it reads JSON, and prose lets each result say what it does not contain — a model that expects a secret back and receives a link will otherwise try again a different way, or apologise to the user for a failure that never happened. Read these strings; do not parse them as a data format.
create_share_via_browser
This tool creates nothing. No share, no short code, no stored row. It returns a link:
https://app.credenshare.io/?compose=<base64url>
The encoded part is base64url of a title and a list of field names — labels only, never values:
{ "title": "Staging deploy credentials", "fields": ["Username", "Password"] }
Opening that link opens the CredenShare app with the share already composed: title and field names filled in, values blank. The person types the values, their browser encrypts them, and the finished share link is shown to them, on that page.
Why it works this way
There are two things a hosted server cannot do, and this shape is what remains once you accept both.
It cannot receive the plaintext. Passing a secret as a tool argument puts it in the model's context and in our logs, which is precisely what this product exists to avoid.
It cannot hand back the finished link either — and this is the part that surprises people. A CredenShare share link carries the decryption key in its URL fragment. Returning that link to the caller would put the key in the model's context and undo the whole arrangement, even though nothing that looked like a secret was ever passed around.
Both problems disappear if the human does the last step. So what the assistant produces here is an invitation, not an artefact.
A few practical consequences:
- Give the link to someone who has a CredenShare account — it opens the web app's create-share screen.
- Whoever opens the link creates the share on their account. The link carries only a title and field names; there is nothing in it that ties the result back to your API key.
- Because the share is created through the app, the normal plan limits apply at that moment, in that person's session.
- The prefill is shape-checked on arrival, because it comes from a URL somebody was handed: the title is truncated to 256 characters and at most 30 field names are accepted. A malformed parameter opens nothing at all.
- The prefill is consumed once, and the
composeparameter is then stripped from the URL.
Notes on the other tools
request_secret_from
Creates a secure request and returns a collect link:
https://crs.sh/r/<short_code>
Give that link to the person who holds the secret. Each field you name is presented to them as a masked password field, in the order you listed them, because these are credentials rather than free text. The link carries no decryption key, so it is safe to send over chat, a ticket, or email — and what they submit through it is never returned to the assistant and never appears in a tool result.
expires_in_days is optional; without it, the request uses your account's default.
If you have a webhook endpoint subscribed to request.submitted, it fires when they submit.
list_shares and get_share
Both are metadata only, because there is no content for them to return.
list_shares lists shares newest first — short code, title and expiry, one per line, preceded by a total and page count when there is more than one page. A limit outside 1–100 falls back to 25 rather than failing the call.
get_share looks up a single short code and reports whether it exists on the account and whether it has expired. It does not consume a view, does not evaluate a passcode, and does not return content. Reading metadata also does not act on expiry — polling your own share must not be the thing that deletes it. A short code belonging to another account reports as not found rather than as forbidden, so the tool cannot be used to find out which codes exist elsewhere.
expire_share
Expires a share immediately so its link stops working. This is irreversible: afterwards the content cannot be recovered by anyone, including CredenShare. It is worth having the assistant confirm with the user before calling it. A short code that is not on the account is reported as such, and nothing is changed.
share_stats
Takes no arguments. Reports how many shares the account has active and expired, how many times they have been viewed in total, and daily view counts for the last 14 days.
Figures only — no titles, no short codes, and nothing about who viewed anything. It is the safest tool on the server to grant, and the one most likely to be what somebody actually wants when they ask an assistant "how are we using CredenShare?".
It requires stats:read, which no other tool uses. A key minted for the share tools will not have it, and the model will be told so rather than silently getting nothing.
On a team account the figures are the organization's, not the individual member's — the same rule the REST endpoint follows, and for the same reason: a seat member has no meaningful figures of their own.
list_webhooks and rotate_webhook_secret
list_webhooks takes no arguments and returns each endpoint's URL, id, subscribed event codes, and whether it is enabled. It never returns a signing secret.
rotate_webhook_secret is the one tool that returns a secret value of any kind: the endpoint's new signing secret, in the result text, shown once. That is defensible only because the value did not exist until the call and is worthless without the endpoint itself. The previous secret keeps verifying for 24 hours, and during that window every delivery is signed with both secrets, so a receiver can be updated without dropping anything.
Store the new secret before the conversation ends — it cannot be shown again. And rotate only once per window: only one previous secret is retained, so rotating twice inside the 24 hours drops the oldest, and a receiver still on it starts failing immediately.
Rate limits
The MCP endpoint is not rate limited today. The /v1 REST endpoints are: they enforce a per-key and per-account budget derived from your plan, and answer 429 with a Retry-After header when a caller exceeds it.
Treat the absence as a property of the current deployment rather than a guarantee. Handle 429 and Retry-After in your client anyway, so that bringing the MCP endpoint under the same budget does not break it.