ADR-0153: Scale VP data through its REST API, behind a first-party bridge that owns its sign-in¶
- Status: Proposed (drafted at the repository owner's request, 2026-10-05)
- Date: 2026-10-05
- Related: ADR-0021, ADR-0071, ADR-0097, ADR-0131
- Detailed design:
docs/plan/tool-system.md
Context¶
The owner wants Chat to read the Scale VP data service at
https://scalevp-mcp.com. The service offers a hosted MCP endpoint and a REST
API under /api/v1/ whose OpenAPI document is published at
/api/openapi.json. Both accept only a
bearer token from the service's own OAuth authorization server, which offers
the authorization-code grant with PKCE and dynamic client registration and
nothing else: no client-credentials grant and no static key (its published
metadata, checked 2026-10-05; the service's operator confirmed OAuth is the
intended route).
Nothing in the platform can complete that grant. tool-system.md defers the user-delegated flows for platform-dialled MCP servers and refuses refresh tokens there, and the shipped egress policy admits no remote MCP destination. The one precedent for an authorization-code grant is Gmail (ADR-0071): a first-party stdio package holds the grant, and the platform never learns that the credential is OAuth.
Decision¶
Add read access to the service as a default-off, non-milestone extension. It authorizes no milestone, changes no registered gate, and does not make third-party MCP servers installable.
The REST API, behind a third sibling package. At the owner's direction
(2026-10-05) access goes through /api/v1/, not the hosted MCP endpoint.
svp_mcp joins gmail_mcp and bland_mcp as an operator-configured stdio
server, svp_read, that cannot import core. Its tool declarations ship with
the release, so the catalog reuse of ADR-0131 holds, and the remote service's
own tool names, descriptions and schemas never reach the advertisement.
Three read tools over the published document. list_operations,
describe_operation and call_operation are driven by the service's OpenAPI
document, which the package fetches with its grant. GET operations are
listed and callable by their path and query parameters, and a query name the
document does not declare is refused. At the owner's decision (2026-10-05)
seven POST operations that search or look up and change nothing are offered
too, from a fixed list in the package: company lookup and semantic search,
contact search, deal search, an investor's co-investors and portfolio, and
the reranked company search. Each takes a bounded JSON object naming only the
properties its request schema declares. Every other operation that is not a
GET stays invisible, including the service's web-cache searches and
fetches, which reach the public web and for which the platform has its own
tools. The row is NETWORK_READ, LOW, READ_ONLY, and requires
mcp.svp_read.use, which composition grants the owner as it does for the
other first-party rows. No write mode exists; one is a separate decision on
the Bland read/call precedent.
Confinement is the package's own. A stdio child is not behind the egress
proxy. The origin, the API root and the three OAuth endpoints are package
constants. Every API request is built from a document path and refused
unless it is HTTPS to that host under /api/v1/ with no dot segment;
redirects are never followed and ambient proxy settings are ignored. Header
and cookie parameters in the document are never sent, and a POST is sent
only to a listed operation's exact URL. Bodies are bounded. A failure the
model cannot correct is a fixed svp.* code with no upstream text.
What the model can correct is an answer. The platform shows the model no text from a failed MCP call, and this service explains a refused request in its message, for example by listing the closest matches to an ambiguous name. A client-error response (400, 404, 409 or 422) is therefore returned as a result holding its status and the service's message, cut to two thousand characters, and a call the package itself refuses returns its code with a fixed hint. Both carry the untrusted label every result of this server carries. Authorization, rate-limit, redirect and server failures stay content-free failures.
Sign-in is a one-time operator ceremony. python -m svp_mcp bootstrap
registers a client, runs the authorization-code grant with PKCE (S256)
through an installed-app loopback redirect on the operator's machine after an
explicit data-use confirmation that precedes any request, proves the grant
against the API by fetching the document, and writes one owner-only file once
the API has accepted the token. A document that is missing or unusable is
reported after the grant is saved, so a wrong location never costs a second
sign-in. The platform's deferral of user-delegated flows stands; no route,
callback or token store is added to the API.
The package owns its grant as a file, and that is the difference from
Gmail. Google's refresh tokens do not rotate, so ADR-0071 passes a read-only
credential by value. This service may rotate its refresh token on use, a
rotated token must be durable before it is relied on, and several server
processes run at once. SVP_CREDENTIAL_FILE therefore names a private state
file; the credential resolver maps svp_read to that path rather than to the
file's contents, and the adapter's existing env scheme places it in the one
declared variable, SVP_MCP_CREDENTIAL_FILE. The package refreshes at use
with a sixty-second skew and no timer, under an exclusive advisory lock,
re-reading the file first so one process's rotation serves the others, and
replaces the file atomically before the new token is used. No token, client
secret or upstream body enters argv, an event, a log or a tool result.
Activation. AGENT_SVP_ENABLED defaults to false. Enabled, it requires
SVP_CREDENTIAL_FILE to be the absolute path of an owner-only regular file,
or startup fails closed. A row named svp_read is composed only by the flag.
The four owner units gain one optional writable path, /var/lib/veetbot/svp,
because under ProtectSystem=strict the grant's holder could not otherwise
replace it.
Alternatives¶
- Dial the hosted MCP endpoint as a tenant HTTP server. This needs the interactive authorization surface the tool system defers, refresh tokens it refuses, and an egress destination the sandbox would share. It is the general "install an MCP server" feature, which nobody has authorized.
- Run the vendor-suggested
npx mcp-remotewrapper. That is a stdio server whose code is downloaded at start, on a host with no Node runtime, and whose sign-in needs a desktop browser. - Pass the credential by value, as Gmail does. A rotated refresh token could not be kept, so the grant would die at its first refresh.
Consequences¶
New chats on a deployment with the flag advertise three more tools through
the existing deferred index; older chats keep their pinned roster. No
dependency, policy rule, adapter change or registered gate is introduced.
The flag has joined the production-roster gates (ADR-0124). On that roster
the calling and Gmail reads fill the last of Chat's thirty definitions and
the Scale VP reads rank after them, so the three tools are offered through
the deferred index and reached with tool.call; no other definition moves.
A scheduled run is not offered the tools: the schedule role composes its own
principal and carries no credentials, and the owner does not need it there.
The grant is one identity's. Whatever that sign-in may read, a run may read on the owner's behalf, and results are external-untrusted text that reaches the hosted model provider like any other tool output; the ceremony says so before the browser opens.
A rotating grant has one holder. A crash between the service rotating a token and the durable write, or a copy of the file left in use on a second machine, breaks the chain, and the fix is to run the ceremony again. The file is moved to the deployment, not copied.
Generic tools cost the model a lookup that typed tools would not. The
document read on 2026-10-05 publishes eighty-six paths: seventy GET
operations and twenty-eight others. The bridge offers the seventy and the
seven listed searches, seventy-seven in all. A new read that the service adds
behind POST is not offered until the list is extended in a release.
The owner signed in on 2026-10-05 and three open facts were settled against
the real service. The document is at /api/openapi.json with its paths
relative to /api, not at the location first assumed, and the constants
were corrected. The REST API accepts the authorization server's tokens,
which last twenty-four hours. Refresh tokens rotate on every use, so the
file-holding design above is required rather than precautionary. A live
smoke through the platform's adapter and the real child process listed the
seventy operations, described and called three of them, refused an
undeclared query and a POST operation, and kept working after a forced
rotation. With the searches added, six of the seven answered through the
bridge, one of them with the service's own request to narrow an ambiguous
company name; the investor portfolio search returned a server error from the
service for the input tried. Offline tests cover the grant, rotation,
confinement and tool behaviour against a scripted transport. Production
enablement remains.