1. A first sanshain.yaml and a snapshot#
The payments team writes the one file every client reads and keeps the version where it
belongs: in the spec. Their trunk pipeline runs cargo sanshain provide --trunk.
Nothing says snapshot; that is the default.
payments/sanshain.yaml
serviceName: payments
catalogInfo: catalog-info.yaml
provides:
- file: openapi.yaml # version read from info.version
payments/openapi.yaml
openapi: 3.0.3
info:
title: Payments
version: 1.0.0
paths:
/payments:
post:
summary: Create a payment
responses:
"201":
description: Created
/payments/{id}:
get:
summary: One payment
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
The client, in the pipeline
# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --trunk
What the catalog holds now
GET /producers/payments/versions HTTP/1.1
Authorization: Bearer san_…
HTTP/1.1 200 OK
content-type: application/json
content-length: 369
[
{
"api_type": "openapi",
"version": "1.0.0",
"stability": "snapshot",
"content_hash": "sha256:c79fec9c98fc8f883b24e080ef5f3a19644c5200c82362f06122186585d4574b",
"provided_by": "root",
"created_at": "2026-10-18T16:07:53Z",
"updated_at": "2026-10-18T16:07:53Z",
"last_required_at": null,
"endpoint_count": 2,
"expires_at": "2026-11-17T16:07:53Z",
"trunk_provided_at": "2026-10-18T16:07:53Z"
}
]
What to notice
-
The version
1.0.0came frominfo.version; the client sent only the stability. - Two endpoints were split out of the document (
endpoint_count: 2). -
A snapshot has an
expires_at: it ages out when nobody provides or requires it (the operator sets the days).trunk_provided_atsays trunk CI was here.
Go deeper
sanshain.yaml: provides · Clients: cargo-sanshain · Getting started: the first provide · POST /provide · GET /producers/{producername}/versions
2. The same number as GA#
The team is happy with 1.0.0 and releases it. Same document, same number,
"stability": "ga"; recorded by hand, to show the exchange the clients make with
--ga.
# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --ga --trunk
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json
{
"producername": "payments",
"stability": "ga",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 1.0.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{id}:\n get:\n summary: One payment\n parameters:\n - name: id\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK",
"trunk": true
}
HTTP/1.1 202 Accepted
content-type: application/json
content-length: 189
{
"version": "1.0.0",
"stability": "ga",
"content_hash": "sha256:c79fec9c98fc8f883b24e080ef5f3a19644c5200c82362f06122186585d4574b",
"changes": {
"inserts": 0,
"updates": 0,
"deletes": 0
},
"promoted": true
}
What to notice
-
"promoted": true: the snapshot became the GA version in place, nothing was stored twice. -
changesare all zero because the bytes are identical; the number is now claimed for good. - Publishing GA needs the releaser role; the token's owner has it.
Go deeper
API lifecycle: going GA · CI integration: stability from the pipeline · Glossary: promotion · POST /provide
3. orders pins an endpoint and fetches its snippet#
The orders team needs one operation. Their sanshain.yaml names the Producer, the
exact version and the endpoint; the JavaScript client fetches the snippet and writes it where
the code generator expects it. Then the same require by hand, to see the headers.
The client, in the orders pipeline
# recorded with curl; the sanshain-js invocation is:
$ sanshain --trunk require
The same require by hand
GET /require?consumername=orders&producername=payments&version=1.0.0&path=/payments&method=POST&trunk=true HTTP/1.1
Authorization: Bearer san_…
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
etag: "3251ba1a99d001bd0ce58d03f79fcd014c43e49557c45c13005d6cf3d9d117b9"
x-sanshain-resolution: served
x-sanshain-version: 1.0.0
x-sanshain-stability: ga
content-length: 181
openapi: 3.0.3
info:
title: Payments
version: 1.0.0
paths:
/payments:
post:
summary: Create a payment
responses:
'201':
description: Created
What to notice
-
The snippet is a complete OpenAPI document that holds only
POST /payments. -
x-sanshain-stability: gasays what answered;x-sanshain-resolution: servedthat the pin resolved. A pin to a version that does not exist is404, a version that exists but lacks the endpoint is410; there is no fallback. -
The
etaglets the next build skip generation when nothing changed; the clients send it for you. -
trunk=truerecords the pin in the main graph, the one a release will be cut from.
Go deeper
sanshain.yaml: requires · Clients: JavaScript · Concepts: pins · GET /require
4. A forgotten bump is refused, with the number to use#
Payments adds a capture endpoint and forgets to touch info.version. The release
pipeline provides the changed document as GA under 1.0.0; the exchange is
recorded by hand.
# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --ga --trunk
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json
{
"producername": "payments",
"stability": "ga",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 1.0.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{id}:\n get:\n summary: One payment\n parameters:\n - name: id\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK",
"trunk": true
}
HTTP/1.1 409 Conflict
content-type: application/json
content-length: 202
{
"error": "version 1.0.0 of openapi 'payments' is GA and immutable, but the submitted content differs — did you forget to increment info's version? Publish as 1.1.0 instead",
"proposed_version": "1.1.0"
}
What to notice
-
409 Conflict, andproposed_versionnames the next number: a new endpoint is a minor change, so1.1.0. - Nothing was stored. The fix is a one-line edit in the spec file, not a pull-merge-retry loop.
-
A pull-request pipeline catches this before the merge with
"dry_run": true.
Go deeper
API lifecycle: the server proposes · CI integration: dry runs · Glossary: proposed version · POST /provide
5. A breaking change behind a minor bump is refused#
The team takes the hint, sets 1.1.0, and in the same change renames the path
parameter of GET /payments/{id}. That removes a path every Consumer may pin. The
release pipeline's exchange, recorded by hand:
# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --ga --trunk
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json
{
"producername": "payments",
"stability": "ga",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 1.1.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK",
"trunk": true
}
HTTP/1.1 409 Conflict
content-type: application/json
content-length: 199
{
"error": "version 1.1.0 of openapi 'payments' is breaking relative to GA 1.0.0 but does not bump the major — publish as 2.0.0 instead: Path '/payments/{id}' was removed",
"proposed_version": "2.0.0"
}
What to notice
-
Sanshain compared against the latest GA,
1.0.0, found a removed path, and asks for2.0.0. - The message names the path, so the decision (rename, or keep the old one deprecated) is an informed one.
Go deeper
API lifecycle: the rules of a version line · CI integration: what an error says
6. 2.0.0 is accepted#
With info.version: 2.0.0 the same document goes through. This time the Go client
publishes it, with the ga switch the release pipeline sets.
The client, in the release pipeline
# recorded with curl; the sanshain-go invocation is:
$ SANSHAIN_GA=true sanshain-go -trunk provide
The version line now
GET /producers/payments/versions HTTP/1.1
Authorization: Bearer san_…
HTTP/1.1 200 OK
content-type: application/json
content-length: 671
[
{
"api_type": "openapi",
"version": "2.0.0",
"stability": "ga",
"content_hash": "sha256:e68af956da1af90a7196425b5be13004d405efbbe340e8690c56e73f34ae3339",
"provided_by": "root",
"created_at": "2026-10-18T16:08:02Z",
"updated_at": "2026-10-18T16:08:02Z",
"last_required_at": null,
"endpoint_count": 3,
"trunk_provided_at": "2026-10-18T16:08:02Z"
},
{
"api_type": "openapi",
"version": "1.0.0",
"stability": "ga",
"content_hash": "sha256:c79fec9c98fc8f883b24e080ef5f3a19644c5200c82362f06122186585d4574b",
"provided_by": "root",
"created_at": "2026-10-18T16:07:53Z",
"updated_at": "2026-10-18T16:07:54Z",
"last_required_at": "2026-10-18T16:07:54Z",
"endpoint_count": 2,
"trunk_provided_at": "2026-10-18T16:07:54Z"
}
]
What to notice
-
Nothing happened to
orders: it pins1.0.0, which stays served unchanged. Pins never move on their own. - The main graph will flag the pin as a major behind trunk; that is information, not a failure.
Go deeper
Clients: Go · Release graphs: the three streams · The web UI: Producers
7. A snapshot is overwritten, then promoted#
Work on 2.1.0 starts. The first provide stores a snapshot; a second provide with more content overwrites it under the same number; when the team is done, the ga switch promotes exactly that content. The three exchanges are recorded by hand:
# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide # twice, as the draft grows; then: cargo sanshain provide --ga --trunk
First snapshot
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json
{
"producername": "payments",
"stability": "snapshot",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.1.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK"
}
HTTP/1.1 202 Accepted
content-type: application/json
content-length: 196
{
"version": "2.1.0",
"stability": "snapshot",
"content_hash": "sha256:13be365243234cf17810a0bae193901868d55e0a2c731d14cd3c3de6b6c3e64c",
"changes": {
"inserts": 3,
"updates": 0,
"deletes": 0
},
"promoted": false
}
The snapshot overwritten (a refund endpoint added)
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json
{
"producername": "payments",
"stability": "snapshot",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.1.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK\n /payments/{id}/refund:\n post:\n summary: Refund a captured payment\n responses:\n \"200\":\n description: OK"
}
HTTP/1.1 202 Accepted
content-type: application/json
content-length: 196
{
"version": "2.1.0",
"stability": "snapshot",
"content_hash": "sha256:bf9e1de18117a28ac401082d4971041fe250238fe9854d727ba9063318bea904",
"changes": {
"inserts": 1,
"updates": 0,
"deletes": 0
},
"promoted": false
}
Promoted to GA
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json
{
"producername": "payments",
"stability": "ga",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.1.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK\n /payments/{id}/refund:\n post:\n summary: Refund a captured payment\n responses:\n \"200\":\n description: OK",
"trunk": true
}
HTTP/1.1 202 Accepted
content-type: application/json
content-length: 189
{
"version": "2.1.0",
"stability": "ga",
"content_hash": "sha256:bf9e1de18117a28ac401082d4971041fe250238fe9854d727ba9063318bea904",
"changes": {
"inserts": 0,
"updates": 0,
"deletes": 0
},
"promoted": true
}
What to notice
-
A snapshot takes new content under the same number without a
409:inserts: 1on the overwrite. -
GA of the same bytes is a promotion (
promoted: true, zero changes); GA of different bytes would be a409like step 4. - Consumers that pinned the snapshot saw it change under them; the ETag told their builds.
Go deeper
API lifecycle: iterating on snapshots · Concepts: snapshot and GA · CI integration: ETags
8. The diff between two versions, and one endpoint's history#
Someone in orders asks what 2.0.0 changed. The diff is one call with a session; the history of a single endpoint is another, and the spec viewer shows both.
The diff
GET /admin/producers/payments/diff?api_type=openapi&from=1.0.0&to=2.0.0 HTTP/1.1
Authorization: Bearer <session token>
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 686
--- payments 1.0.0
+++ payments 2.0.0
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Payments
- version: 1.0.0
+ version: 2.0.0
paths:
/payments:
post:
@@ -9,14 +9,20 @@
responses:
"201":
description: Created
- /payments/{id}:
+ /payments/{paymentId}:
get:
summary: One payment
parameters:
- - name: id
+ - name: paymentId
in: path
required: true
schema: { type: string }
responses:
"200":
description: OK
+ /payments/{id}/capture:
+ post:
+ summary: Capture an authorised payment
+ responses:
+ "200":
+ description: OK
The trail of POST /payments
GET /endpoint-versions?service=payments&api_type=openapi&path=/payments&method=POST HTTP/1.1
Authorization: Bearer san_…
HTTP/1.1 200 OK
content-type: application/json
content-length: 958
[
{
"version": "1.0.0",
"stability": "ga",
"provided_by": "root",
"updated_at": "2026-10-18T16:07:54Z",
"yaml_content": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 1.0.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n '201':\n description: Created\n",
"changed": true
},
{
"version": "2.0.0",
"stability": "ga",
"provided_by": "root",
"updated_at": "2026-10-18T16:08:02Z",
"yaml_content": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.0.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n '201':\n description: Created\n",
"changed": true
},
{
"version": "2.1.0",
"stability": "ga",
"provided_by": "root",
"updated_at": "2026-10-18T16:08:02Z",
"yaml_content": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.1.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n '201':\n description: Created\n",
"changed": true
}
]
What to notice
-
The diff is a unified patch of the stored documents;
pathandmethodnarrow it to one snippet. - The trail answers the snippet as stored in each version and marks the ones that changed it.
- The diff route is administrative and takes a session; the trail route takes an API token, so a script can ask.
Go deeper
The web UI: the spec viewer · GET /admin/producers/{name}/diff · GET /admin/endpoint-versions (the session twin of the call above)
9. A release branch is cut, and a report scoped to it#
Release day. A releaser cuts a sanshain-branch: a named copy of the main graph as it is now. The report scoped to it describes what production will be; a hotfix pipeline on that release requires with the branch's tag, here with the Conan client.
The cut
POST /admin/branches HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"name": "release-2026.10"
}
HTTP/1.1 201 Created
content-type: application/json
content-length: 137
{
"id": 1,
"name": "release-2026.10",
"created_at": "2026-10-18T16:08:03Z",
"created_by": "root",
"source": "trunk",
"as_of": "2026-10-18T16:08:03Z"
}
The report for the release
GET /report?scope=release-2026.10 HTTP/1.1
Authorization: Bearer san_…
HTTP/1.1 200 OK
content-type: application/json
content-length: 356
{
"unused_endpoints": [],
"missing_endpoints": [],
"dependency_graph": [
{
"api_type": "openapi",
"client": "orders",
"service": "payments",
"version": "1.0.0",
"stability": "ga",
"path": "/payments",
"method": "POST",
"deprecated": false
}
],
"service_tags": {},
"trunk_graph": [],
"harvested_subscriptions": [],
"peripheral_dependencies": [],
"systems": {},
"scope_label": "release-2026.10"
}
A hotfix build of orders on the branch
# recorded with curl; the sanshain-conan invocation is:
$ sanshain-conan --tag release-2026.10 require
What to notice
-
The branch records
source: trunkand the instant it was born from; the pin set isorders → payments 1.0.0, the trunk pin. -
scope_labelstamps the report;?scope=mainor?scope=release-2026.10@<instant>work the same way. - The tagged require updates the branch's graph and nothing else; the version rules are untouched.
Go deeper
Release graphs: cutting a branch · CI integration: streams · Clients: Conan · POST /admin/branches · Using the API: reports and scopes
10. The catalog push: periphery and context#
catalogInfo: catalog-info.yaml in step 1's file makes every build push the
Backstage descriptor too. The Maven plugin does it here; Sanshain reads the owner, the
description, the source location, the links and the dependsOn resources, and the
node gets a context.
The push, in the pipeline
# recorded with curl; the the Maven plugin invocation is:
$ mvn io.github.paxel.sanshain:sanshain-maven-plugin:2.2.0:catalog -Dsanshain.trunk=true
What the Producer page reads
GET /admin/producers/payments HTTP/1.1
Authorization: Bearer <session token>
HTTP/1.1 200 OK
content-type: application/json
content-length: 402
{
"name": "payments",
"context": {
"owner": {
"kind": "group",
"name": "team-payments"
},
"description": "Takes payments for the shop and hands them to the bank.",
"source_location": "https://github.com/acme/payments/tree/main/",
"links": [
{
"url": "https://wiki.acme.corp/payments",
"title": "Runbook",
"type": "docs",
"icon": null
},
{
"url": "https://grafana.acme.corp/d/payments",
"title": "Dashboard",
"type": null,
"icon": null
}
]
}
}
What to notice
-
Only
resource:refs become peripheral nodes; edges between systems stay Sanshain's own. - A repeat push of an unchanged file changes nothing and emits no event.
-
A file Sanshain cannot read as the project's Component is still
200, withrecorded: falseand aSanshain-Warning.
Go deeper
CI integration: the catalog push · sanshain.yaml: the catalog phase · Clients: Maven · PUT /catalog/{systemname} · GET /admin/producers/{name}
11. The free validator, before publishing#
For the next version the team checks the draft locally first.
POST /validate needs no token and stores nothing; it runs the same parsing,
version extraction and splitting a provide would.
A valid draft
POST /validate HTTP/1.1
Content-Type: application/json
{
"api_type": "openapi",
"content": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.2.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK\n /payments/{id}/refund:\n post:\n summary: Refund a captured payment\n responses:\n \"200\":\n description: OK"
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 190
{
"valid": true,
"api_type": "openapi",
"version": "2.2.0",
"endpoint_count": 4,
"endpoints": [
"POST /payments",
"GET /payments/{paymentId}",
"POST /payments/{id}/capture",
"POST /payments/{id}/refund"
]
}
The same draft without a version
POST /validate HTTP/1.1
Content-Type: application/json
{
"api_type": "openapi",
"content": "openapi: 3.0.3\ninfo:\n title: Payments\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK\n /payments/{id}/refund:\n post:\n summary: Refund a captured payment\n responses:\n \"200\":\n description: OK"
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 146
{
"valid": false,
"api_type": "openapi",
"error": "spec has no info.version — the version is required and lives in the spec file (MAJOR.MINOR.PATCH)"
}
What to notice
-
Always
200with a verdict, so a script parses one shape;valid: falsecarries the reason. - The version-line rules (steps 4 and 5) are not part of it: those need the Producer's history and run on a real provide or a dry run.
Go deeper
12. An AI assistant asks the catalog over MCP#
A developer configures Claude Code with a read-only token. The token is minted with the
account page's call; the assistant's question travels as a JSON-RPC tools/call;
and the same token is refused the moment it tries to publish.
A read-only token
POST /auth/tokens HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"name": "claude-code",
"expires_in_days": 90,
"scope": "read_only"
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 165
{
"id": "376e96e4-3808-4c5f-a6c4-0e4c9deacd14",
"name": "claude-code",
"scope": "read_only",
"token": "san_…"
}
"Who owns payments, and where is its code?"
POST /mcp HTTP/1.1
Authorization: Bearer san_… (read_only)
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe_service",
"arguments": {
"name": "payments"
}
}
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 2025
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"at\":null,\"consumers\":[{\"api_type\":\"openapi\",\"endpoints\":[{\"method\":\"POST\",\"path\":\"/payments\"}],\"name\":\"orders\",\"version\":\"1.0.0\"}],\"context\":{\"description\":\"Takes payments for the shop and hands them to the bank.\",\"links\":[{\"icon\":null,\"title\":\"Runbook\",\"type\":\"docs\",\"url\":\"https://wiki.acme.corp/payments\"},{\"icon\":null,\"title\":\"Dashboard\",\"type\":null,\"url\":\"https://grafana.acme.corp/d/payments\"}],\"owner\":{\"kind\":\"group\",\"name\":\"team-payments\"},\"source_location\":\"https://github.com/acme/payments/tree/main/\"},\"domain\":null,\"endpoints\":[{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"POST\",\"path\":\"/payments\",\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"GET\",\"path\":\"/payments/{paymentId}\",\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"POST\",\"path\":\"/payments/{id}/capture\",\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"POST\",\"path\":\"/payments/{id}/refund\",\"version\":\"2.1.0\"}],\"icon\":null,\"is_consumer\":false,\"is_producer\":true,\"name\":\"payments\",\"periphery\":[{\"resource\":\"resource:default/bank-gateway\",\"shared_with\":[]},{\"resource\":\"resource:default/payments-db\",\"shared_with\":[]}],\"requires\":[],\"scope\":\"dev\",\"versions\":[{\"api_type\":\"openapi\",\"endpoint_count\":4,\"last_provided_at\":\"2026-10-18T16:08:02Z\",\"provided_by\":\"root\",\"stability\":\"ga\",\"superseded\":false,\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"endpoint_count\":3,\"last_provided_at\":\"2026-10-18T16:08:02Z\",\"provided_by\":\"root\",\"stability\":\"ga\",\"superseded\":true,\"version\":\"2.0.0\"},{\"api_type\":\"openapi\",\"endpoint_count\":2,\"last_provided_at\":\"2026-10-18T16:07:54Z\",\"provided_by\":\"root\",\"stability\":\"ga\",\"superseded\":true,\"version\":\"1.0.0\"}]}"
}
],
"isError": false
}
}
The same token on a provide
POST /provide HTTP/1.1
Authorization: Bearer san_… (read_only)
Content-Type: application/json
{
"producername": "payments",
"stability": "ga",
"openapi_yaml": "openapi: 3.0.3\ninfo:\n title: Payments\n version: 2.1.0\npaths:\n /payments:\n post:\n summary: Create a payment\n responses:\n \"201\":\n description: Created\n /payments/{paymentId}:\n get:\n summary: One payment\n parameters:\n - name: paymentId\n in: path\n required: true\n schema: { type: string }\n responses:\n \"200\":\n description: OK\n /payments/{id}/capture:\n post:\n summary: Capture an authorised payment\n responses:\n \"200\":\n description: OK\n /payments/{id}/refund:\n post:\n summary: Refund a captured payment\n responses:\n \"200\":\n description: OK"
}
HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 153
{
"error": "this API token has scope 'read_only' and may not change the catalog; create a token with scope 'full' for provide, require and catalog pushes"
}
What to notice
- The tool answers the version lines, the consumers and their pins, and the context from step 10, in one compact document.
- A read-only token cannot provide, require or push, whoever owns it: the scope is fixed at creation.
-
In Claude Code the configuration is one line:
claude mcp add --transport http sanshain https://sanshain.corp/mcp --header "Authorization: Bearer san_…".
Go deeper
MCP: the token · MCP: client configuration · MCP: the tools · POST /auth/tokens · Using the API: scopes