Spec source
A spec source is a machine-readable description of an API that Tinspec keeps reading. Endpoints are generated from it. In the app these are surfaced as API providers.
It started as OpenAPI-only. At v0.1.0-preview.10 it is source-kind-agnostic: a document you fetch, a gRPC server’s own reflection service, a GraphQL endpoint’s introspection, and an MCP server’s tool catalogue are all the same thing to Tinspec — something you point at, generate operations from, and re-resolve on an interval.
kind | location points at | What an “endpoint” is |
|---|---|---|
openapi (the default) | An OpenAPI document, by URL or file path | One operation |
grpcReflection | A grpc:// / grpcs:// target whose reflection service is queried | One method |
graphql | The HTTP endpoint the introspection query is POSTed to | One root field |
mcp | An MCP server’s streamable-HTTP endpoint, whose tools/list is called | One tool |
Absent kind means openapi, so every spec source written before the field existed keeps its
exact meaning and serializes byte-identically.
A source kind names how the description is obtained, not how the resulting requests are sent.
A gRPC service behind a transcoding gateway is still described by grpcReflection even though
its requests go out as HTTP; and the mcp source kind (how a tool catalogue is described) is a
different axis from the mcp protocol
(how one tool call is sent).
Because every kind produces the same endpoint shape, the sidebar, the docs pane, overlays, the seen baseline, Spec Changes and the agent’s tools all work for a gRPC service and a GraphQL endpoint without learning anything new.
What “live binding” means
Section titled “What “live binding” means”Importing a spec copies it once. Binding keeps reading it.
That is the whole difference from every tool that treats reflection or introspection as a
one-shot import: you press a button, it writes a .proto or an SDL file, and that file rots.
pollIntervalSecs is what turns a source into a binding. It is in seconds, and absent (or
0) means never poll — an explicit default, so nothing starts making background requests
because a field was missing. With it set, each resolve produces the current endpoint set, so:
- an operation added to the description appears;
- one removed from it disappears;
- one whose parameters, body, or responses changed updates in place.
You are never testing against last quarter’s snapshot. When the service is unreachable, the last successfully fetched document is served from the local cache, so the endpoints stay usable offline.
A poll is an ordinary authenticated call: a reflection RPC rides the same channel, TLS material and injected metadata as the RPC it describes, and an introspection query is an ordinary GraphQL send. So a service behind a bearer token is pollable with no special casing.
File-backed specs are watched, not just polled
Section titled “File-backed specs are watched, not just polled”A large OpenAPI document is split — openapi.yaml plus schemas/user.yaml plus
responses/errors.yaml — and an edit to any of them changes the API.
For a local file source, Tinspec walks the root document and every local $ref reachable
from it, then stamps that set as mtime:size per file. A tick compares the stamp with the
previous one; a difference means something under this spec changed on disk. It is deliberately a
stat, not a content hash — a spec split across a hundred files should cost a hundred stats per
tick, not a hundred reads.
Remote $refs are not watched. They are a real part of the spec, but nothing on this machine
can watch an HTTP URL, so they are left to the ordinary poll interval. A ref that cannot be read
at all is reported rather than dropped.
gRPC has a second axis
Section titled “gRPC has a second axis”A gRPC request says where its descriptor comes from, independently of whether there is a spec
source at all: descriptorSource is proto (a .proto file at protoPath), descriptorSet (a
compiled FileDescriptorSet — a protoset from protoc --descriptor_set_out or buf build), or
reflection (the running server’s own grpc.reflection.v1 service, with a v1alpha fallback).
Absent still means proto, so an existing .proto-backed request writes no new key and stays
byte-identical in your git repo.
Saving a reflection poll for offline use writes a protoset — a byte copy of what the poll
returned, not a lossy export back into .proto source.
Two limits at this release: a .proto-backed provider gets no schema diff at all, and a gRPC
reflection poll costs two round trips.
GraphQL: an endpoint is a root field
Section titled “GraphQL: an endpoint is a root field”For a GraphQL source the unit is one root field — query.user, not the User type. That is
what a caller actually calls; a type is not callable, and every root field POSTs to the same URL.
Grouping follows the root operation, so the list reads Query / Mutation / Subscription with their
fields under them. The __-prefixed meta-types are skipped.
The introspected schema also drives a type explorer and query completion in the editor. Nothing
there changes what a send carries — introspection is a side channel that changes what the editor
can offer, never the {query, variables, operationName} the engine posts.
Completion is a brace-and-paren scanner, not a GraphQL parser, and it is honest about it: field
names at any depth, argument names, input types after $name:, aliases, inline fragments and
fragment definitions all resolve. Fragment spreads, a union’s selection set, argument and enum
values, variable names, and anything past a syntax error all return nothing, deliberately —
an empty popup is the signal that the cursor is somewhere the scanner cannot reason about, and a
confidently wrong completion costs more.
Changes are reviewed, not silent
Section titled “Changes are reviewed, not silent”A live binding that silently rewrote things under you would be worse than a snapshot. So every difference lands in the Spec Changes view, which keeps a per-endpoint snapshot of what it last showed you and diffs against it field by field.
A modified endpoint expands to show exactly what moved. A removed endpoint keeps its last-known shape so you can still read what it used to be.
Acknowledging is always an explicit act — a per-row tick, Mark seen for one provider, or Mark all seen. Opening an endpoint to inspect it does not acknowledge anything.
From the same view you can tick endpoints and copy a minimal, valid OpenAPI subset of just those operations — with path-level parameters, every transitively referenced schema, and the security schemes, and nothing else. Useful for a ticket, a code review, or handing an agent exactly the part of the API under discussion. Removed endpoints can be reconstructed into the subset from their last-seen snapshot.
Breaking or not
Section titled “Breaking or not”Differences between two polls are classified by the engine, so a change set separates the breaking ones from the additive ones instead of presenting a flat list.
The classifier has one thing it cannot say, and it is worth knowing: “DELETE /legacy was
removed and two of your chains still call it.” Usage is resolved against live endpoints, so
an operation that has gone is an operation nothing can be matched to. What the same machinery
does answer is the deprecation case — which deprecated endpoints your saved requests and
chains still point at — because those are still in the spec.
Flags the spec already carries
Section titled “Flags the spec already carries”An operation marked deprecated gets a badge, and so does one marked x-internal.
Internal endpoints are hidden by default, behind Settings → Endpoints → Show internal
endpoints. Every surface that hides them reports how many (N internal hidden), so a count
never silently lies about what you are looking at.
servers[] → environments
Section titled “servers[] → environments”An OpenAPI document usually declares several servers. Tinspec builds endpoint URLs from the first, so the rest were invisible; a provider action now turns them into project environments, skipping any name that already exists rather than overwriting it.
Read the caveat, because it depends on the document: a spec with no servers[] yields
{{baseUrl}}/path endpoint URLs, so the environment really does decide where a request goes. A
spec with servers[] yields absolute URLs built from servers[0], in which {{baseUrl}}
appears nowhere — so creating these environments does not retarget the generated endpoints.
It supplies {{baseUrl}} to requests you author or fork, to chains, and to the gateway base-URL
field. The dialog says so.
Reconciling generated endpoints with your edits
Section titled “Reconciling generated endpoints with your edits”The tension in a live binding: the endpoint is regenerated every poll, but you tweaked it.
Tinspec resolves this with overlays. When you edit a generated endpoint, it diffs your
version against a pristine clone and records only the field groups that differ, as
committable YAML in .tinspec/overlays/<provider-id>.yaml. Every fresh resolve re-applies them.
There are nine groups: body, query, headers, auth, grpc, graphql, prerunChain,
routeMatcher, and settings.
Rules worth knowing:
- An overlaid group replaces the generated one wholesale — it is not a field-level merge.
- A group written as
nullrecords that you deleted the generated value, as distinct from absent, which means untouched. Without that distinction a reset did not survive the next poll. - URL and method are not overlayable. They form the endpoint’s identity, which is also the key for overlays, the change baseline, and tab state.
- When a poll changes an endpoint underneath an existing edit, the editor shows an amber banner offering Keep edits or Reset to spec. It will not choose for you.
An edited endpoint carries an Edited chip in the editor and a dot in the navigator, and offers Reset to spec at any time.
The overlay file is also where a saved example lives — a response you kept under a name on the endpoint it came from. It is deliberately not a request group, so saving an example never changes what the endpoint sends.
The overlay file on disk is a bare endpointId → overlay map with no formatVersion key at this
release, and so is a provider file under .tinspec/providers/.
Mocking from the binding
Section titled “Mocking from the binding”A bound spec can also serve — an HTTP mock built from the same resolved spec the endpoint list, the drift banner and the version timeline come from, re-read on every request. There is no generated artifact to regenerate and no import step to forget, so the mock is only ever as old as the last poll, and Spec Changes is literally the changelog of what changed in the mock.
Three things follow from being bound rather than generated: responses are the spec’s own examples (or the same synthesized ones the docs pane shows); inbound requests are validated against the declared shape, so a caller’s missing required parameter fails instead of getting a cheerful 200; and an injected fault can only be one of the operation’s own declared errors.
Bodies can be seeded from your real 2xx sends, secret-scrubbed, so the mock returns real ids and real nesting.
The limits are worth stating plainly: HTTP only — no gRPC and no GraphQL mocks; no stored
state — a POST does not make the next GET return the created row; no TLS (it binds
loopback); options persist but nothing auto-starts; and mocks stop when you switch projects.
In the file format
Section titled “In the file format”A spec source in a collection is a name, a location, an optional kind and poll interval, and an optional route matcher binding:
specSources: - name: Users API location: http://localhost:8080/openapi.json pollIntervalSecs: 60 routeMatcher: matcher: edge-kong upstream: users-service gatewayUrl: "{{gatewayUrl}}" - name: Billing gRPC kind: grpcReflection location: grpc://localhost:50051 pollIntervalSecs: 30In a project with a folder, API providers are also written one YAML file per provider under
.tinspec/providers/.
A provider’s spec source URL is read-only after creation at v0.1.0-preview.10 — the settings view shows it with a copy button and no edit field.