The OCTool descriptor
Version: 1.4.0 ยท Date: 2026-11-09
OCTool is a frozen descriptor of one capability a library offers to an
agent runtime: a keyword-only dataclass in Python, a class built with
new OCTool({ ... }) in TypeScript. This page is the reference: every
field, the result envelopes, when a tool may skip a result schema, what
"strict-clean" means for an input schema, and how the format and the
tools themselves evolve. octools.SPEC_VERSION is "1.3".
Fields
Construction validates the structural rules below and raises
OCToolError on the first failure (a ValueError in Python, an Error
subclass in TypeScript). Policy rules (marked
validator) are reported as findings by validate_tool and
validate_provider instead, so a library can list every problem at once.
| Field | Type | Default | Meaning |
|---|---|---|---|
name |
str |
required | Matches ^[a-z][a-z0-9_]{0,47}$: lowercase snake_case, at most 48 characters counting the provider's namespace (demo_get_rows), leaving 16 for an agent host's routing prefix under the tightest common name limit (64). |
description |
str |
required | Written for a model to read: what it does, when not to use it, what it returns, one caveat. Two to four sentences (validator); avoid abbreviations such as e.g., the sentence counter splits on . followed by a space. |
input_schema |
Mapping[str, Any] |
required | JSON Schema 2020-12 with an object root, generated from a pydantic model by input_schema_for. Must be strict-clean (validator). |
func |
Callable[..., Any] |
required | func(dependency, **args): the injected dependency first (a client, a workspace, a sink), validated keyword arguments after. Returns a JSON object. |
access |
"local" or "remote" |
required | Where the tool acts: in-process data or a remote system of record. remote means the tool reaches that system through its injected dependency, so a runtime applies its remote policy (approval, timeouts, audit) to the call. |
target |
str \| None |
None |
Optional free-text label for the access target a library fills in, for example "cluster". |
output_schema |
Mapping[str, Any] \| None |
None |
JSON Schema 2020-12 for the result, generated by output_schema_for. Preferred, not mandatory: see Escape conditions (validator). |
result_kind |
"records" or "artifact" |
"records" |
Which envelope the result uses, so a runtime knows whether to read the result or to embed and cite it. |
title |
str \| None |
None |
Optional human label (MCP title). |
read_only_hint |
bool |
True |
MCP readOnlyHint. Describes the access target, not the filesystem: writing through an injected sink is still read-only with respect to the system of record. |
destructive_hint |
bool |
False |
MCP destructiveHint; meaningful when not read-only. |
idempotent_hint |
bool |
True |
MCP idempotentHint; meaningful when not read-only. |
deprecated |
bool |
False |
An alias kept alive after a rename; must name the replacement in meta["alias_of"] (validator). |
meta |
Mapping[str, Any] |
{} |
Library facts the consumer may read, plain snake_case keys: min_api_version, schema_escape, alias_of. Exported to MCP under _meta["com.opscogs.octools/meta"]. |
tags |
tuple[str, ...] |
() |
What the tool is, for a host to select by: recommended (load by default) or advanced (load on request). See Tool tags. |
The table gives the Python names and types. In TypeScript every field is
camelCase (input_schema is inputSchema, read_only_hint is
readOnlyHint, result_kind is resultKind), a schema or meta is a
plain object, an unset optional field reads as null, and an unknown
field in the constructor argument raises OCToolError.
Hints are advisory. They inform a runtime's approval policy; they never replace it, and a runtime must treat them as untrusted unless it trusts the library.
The callable contract
func takes the injected dependency positionally and validated keyword
arguments. It validates its own inputs (a pydantic model is the usual
way), does no I/O beyond the dependency, reads no environment, never
prints, never exits, and lets library exceptions propagate so the caller
maps them. Invalid arguments raise ValueError (pydantic's
ValidationError is one); in TypeScript, parsing the arguments with the
tool's zod schema throws ZodError. Numeric bounds such as "1 to 100"
live in the description and in this validation, not in the schema.
TypeScript's ToolFunc leaves the parameter list to the runtime; the
examples on this site follow the same contract with the arguments as one
object, func(dependency, args).
func is a plain synchronous callable. Construction raises OCToolError
for a coroutine or async function or an async-generator function. An async or
streaming variant, when one is needed, arrives as a separate optional
field that an older runtime ignores, never as a change to func itself.
For runtime authors
A runtime looks a tool up by name in all_tools() (allTools() in
TypeScript), validates the
arguments against input_schema (or lets the tool do it, since it always
validates its own), and calls func(dependency, **args). The dependency
is whatever the library documents for its tools; pass None when it
documents that none is needed. The result is a JSON object and, when
output_schema is present, conforms to it. to_mcp_tool and
to_anthropic_tool drop func, so the runtime keeps the descriptor for
dispatch. validate_provider takes a provider object or module with an
all_tools() method, not a list (a list raises AttributeError), and
calls all_tools() twice to check that the order is stable. In
TypeScript a module namespace (import * as myTools) with an exported
allTools function is a provider.
Discovery
A library exposes all_tools() returning its descriptors in a fixed
order with unique names; ToolProvider is the protocol (an interface in
TypeScript), and a module with an all_tools function (allTools)
satisfies it. validate_provider
calls it twice and reports unstable_order or duplicate_name. MCP asks
for deterministic listings so prompt caches stay warm.
A runtime that registers more than one library's tools validates the union,
not each half: a name two libraries share is only visible there.
merge_providers(*providers) (mergeProviders(...providers)) returns a
provider whose all_tools()
concatenates theirs in argument order. It deduplicates nothing on purpose, so
a collision comes back as a duplicate_name finding instead of one tool
quietly winning.
import assert from "node:assert/strict";
import { mergeProviders, validateProvider } from "@opscogs/octools";
import * as myTools from "./my_tools.js";
import * as partnerTools from "./partner_tools.js";
const registered = mergeProviders(myTools, partnerTools);
assert.deepEqual(validateProvider(registered), []);
Result envelopes
A result is always a JSON object. Three models define the shapes:
pydantic models in Python, zod schemas in TypeScript. Their JSON Schemas
come from records_schema(Item), tabular_schema() and
artifact_schema() (recordsSchema(item), tabularSchema(),
artifactSchema()).
Records (result_kind="records"), for results whose record shape is
known in advance:
A page of a longer result adds the optional paging fields:
{ "records": ["..."], "count": 2, "truncated": true, "total_count": 3, "next_cursor": "...", "resumable": true }
RecordsEnvelope[Item] is generic over the item model; in TypeScript,
RecordsEnvelope(item) returns the schema over records that item
parses. truncated is
true when a cap, a stop or an interrupted read cut the list short;
count is the number returned and must equal the length of records, or
the envelope rejects it. total_count is optional: the exact size of the
full result when the source knows it, omitted when unknown or
approximate. It is int | str | None: a JSON integer up to 2^53 - 1, or -
above that - a non-negative decimal string (^(0|[1-9][0-9]*)$), the same
rule as the big_integer column kind (below); a producer may send a
string for any value, and a reader compares it numerically, never
lexically. next_cursor is optional: an opaque token present only when
truncated is true. stop_reason is optional: why a truncated read
stopped early. resumable is computed, true exactly when next_cursor
is present, and in the serialized JSON only when truncated is true
(see Paging). warnings is optional: non-fatal problems the
caller should relay, one string per entry (see
Warnings on list envelopes). The optional
fields are left out of the serialized JSON when absent, and warnings
when empty; model_dump does this itself, so a tool never needs
exclude_none. In TypeScript a parse (RecordsEnvelope(item).parse(data))
returns that serialized form directly, ready for JSON.stringify.
Tabular, for results whose columns are only known at run time (an
ad-hoc SQL result). There is no tabular result_kind: a tabular tool
keeps result_kind="records" and a runtime detects the shape from the
columns and rows properties of its output schema.
{
"columns": ["host", "count"],
"rows": [["a", 1]],
"row_count": 1,
"truncated": false,
"column_kinds": ["fqdn", "integer"]
}
TabularEnvelope takes columns, rows, row_count, truncated, and
optionally column_kinds, column_scales, column_descriptions,
total_count, next_cursor, stop_reason and warnings; a truncated
envelope's dump adds the computed resumable. Cells are
scalars, Cell = str | int | float | bool | None
(string | number | boolean | null in TypeScript), and row_count must
equal the length of rows. A tabular tool declares tabular_schema()
as its output schema:
from octools import OCTool, TabularEnvelope, input_schema_for, tabular_schema
def host_table(_dependency, *, region: str) -> dict:
rows = [[h["host"], h["cores"]] for h in HOSTS if h["region"] == region]
return TabularEnvelope(
columns=["host", "cores"],
rows=rows,
row_count=len(rows),
truncated=False,
column_kinds=["fqdn", "integer"],
).model_dump(mode="json")
HOST_TABLE = OCTool(
name="host_table",
description="...",
input_schema=input_schema_for(QueryArgs),
output_schema=tabular_schema(),
func=host_table,
access="local",
)
import {
OCTool,
TabularEnvelope,
inputSchemaFor,
tabularSchema,
} from "@opscogs/octools";
function hostTable(_dependency: unknown, args: unknown): TabularEnvelope {
const { region } = QueryArgs.parse(args);
const rows = HOSTS.filter((h) => h.region === region).map((h) => [
h.host,
h.cores,
]);
return TabularEnvelope.parse({
columns: ["host", "cores"],
rows,
row_count: rows.length,
truncated: false,
column_kinds: ["fqdn", "integer"],
});
}
export const HOST_TABLE = new OCTool({
name: "host_table",
description: "...",
inputSchema: inputSchemaFor(QueryArgs),
outputSchema: tabularSchema(),
func: hostTable,
access: "local",
});
column_kinds is optional, one entry per column, drawn from the closed
vocabulary COLUMN_KINDS (ColumnKind is the matching Literal, or
union type in TypeScript, ten kinds); any other value is a validation
error on construction, model_validate or a TypeScript .parse. Like the other optional envelope
fields, it is left out of the serialized JSON when absent. The kinds are
what a renderer needs to align, format or chart a column, and each maps
onto a JSON Schema type or format:
| Kind | JSON Schema | Render |
|---|---|---|
text |
string |
- |
number |
number |
- |
integer |
integer |
- |
boolean |
boolean |
- |
timestamp |
string, format date-time |
- |
ip |
string, format ipv4 or ipv6 |
- |
fqdn |
string, format hostname |
- |
mac |
string |
- |
ip_block |
string: an address, a CIDR prefix, or a first-last range |
monospace, left-aligned |
big_integer |
integer, or string of decimal digits with an optional - |
right-aligned |
Kinds are not JSON Schema format names: write fqdn and ip, not
hostname or ipv4, which belong to an input schema's format.
ip_block holds any contiguous set of addresses in one family, written as
a bare address (a block of one), address/length, or first-last; a
column that only ever holds single addresses stays ip. big_integer
holds an integer of any size: a cell is a JSON integer when its magnitude
fits in 2^53 - 1 and a decimal string otherwise, so a reader never loses
precision and a small value never needs parsing. Parsing never checks a
cell against its declared kind - that is validate_tabular, below.
column_scales
column_scales is optional, one entry per column, parallel to columns
and column_kinds: list[ColumnScale | None] | None, drawn from the
closed vocabulary COLUMN_SCALES (ColumnScale is the matching
Literal or union type, "linear" | "log10" | "log2"). TabularEnvelope rejects a
column_scales list of the wrong length. A non-None entry requires
column_kinds to be present and that column's kind to be number,
integer or big_integer; any other combination is a validation error
on construction or model_validate, since a scale on an fqdn column has
no sensible reading. A tolerant reader relaxes this one cross-field check:
read_envelope (below) reads a scale on a column whose kind is
non-numeric, whose kind degraded to text, or where column_kinds is
absent altogether, as None rather than rejecting the envelope. Omitted,
or None for one column, the envelope serializes exactly as before and a
renderer applies its own data-driven rule (below).
{
"columns": ["prefix", "prefix_len", "size"],
"column_kinds": ["ip_block", "integer", "big_integer"],
"column_scales": [null, null, "log2"],
"rows": [
["2001:db8::/32", 32, "79228162514264337593543950336"],
["2001:db8::/64", 64, "18446744073709551616"]
],
"row_count": 2,
"truncated": false
}
column_descriptions
column_descriptions is optional, one entry per column, parallel to
columns: list[str | None] | None, a short description of what each
column holds, None for a column with none. It gives a schema-less
tabular result (an ad-hoc query over terse source column names) the
meaning a records tool gets from its item schema's field descriptions.
TabularEnvelope rejects a list of the wrong length, and the field is
left out of the serialized JSON when absent. read_envelope drops a
malformed column_descriptions - not a list, the wrong length, or an
entry that is neither a string nor null - and reads the rest of the
envelope, since a description is a hint and always safe to drop.
A paged producer may send column_descriptions on the first page only,
to save tokens: the columns do not change between pages, so a caller
keeps the descriptions it was first given.
{
"columns": ["hn", "vc"],
"column_descriptions": ["Host name.", "Virtual CPU count."],
"rows": [["a", 4]],
"row_count": 1,
"truncated": false
}
Renderer guidance
A renderer charting a numeric column never rewrites the cell - the
envelope stays exact end to end - and prints the original cell for every
value a person reads (table cells, tooltips, data labels, exports),
sorting a big_integer column by BigInt(cell) while plotting
Number(cell): a JavaScript float64 carries enough significant digits
that plotting it is visually exact even at 2^128, so precision is never
the reason to avoid a linear axis.
- A declared scale wins:
log2draws a log axis ticked at whole powers of two (2^0,2^32,2^64, ...);log10ticks at powers of ten;linearstays linear even when the range is wide. - With no declared scale, a renderer uses a logarithmic axis when every plotted value is positive and the largest is at least 1000 times the smallest, and linear otherwise, falling back to linear when a value is zero or negative.
- Base-10 ticks format compactly (
3.4 x 10^38); exact values stay in tooltips and labels, never in the plotted or tick data itself. - A renderer that cannot draw a given scale, or is not charting at all,
ignores
column_scales.
tabular_from_models
tabular_from_models(model, items, *, column_kinds=None)
(tabularFromModels(schema, items, { columnKinds }), over a zod object)
builds a TabularEnvelope from a model, so every producer maps a
field's type to a column kind the same way. Columns are the fields in
declaration order, and the kind comes from the type alone:
| Field type | Kind |
|---|---|
bool, int, float |
boolean, integer, number |
Optional[X], Annotated[X, ...] |
the kind of X |
A union, enum or Literal |
the kind its members share, number for integers mixed with numbers |
AwareDatetime |
timestamp |
| An IP address type | ip |
| An IP network or interface type | ip_block |
| A list, dict or nested model | text, as compact JSON |
A non-string value in a text column |
compact JSON |
z.bigint() (TypeScript) |
big_integer, as a decimal string |
Anything else, datetime and date too |
text |
A plain datetime is text because it may lack the offset timestamp
requires. column_kinds overrides per column, which is how a column becomes
timestamp from a plain datetime, or big_integer or fqdn; a key that is
not a column raises OCToolError. Field descriptions fill
column_descriptions when any field has one, and a value the dump omits is a
null cell, as is a non-finite float. truncated and next_cursor (nextCursor) describe a capped read
and go through the envelope's own checks. Run validate_tabular on the result
in your tests: an int beyond 2^53 - 1 needs the big_integer
override.
validate_tabular
An opt-in check exported from the package root: TabularEnvelope never
calls it on parse, so a large table pays no cost on read and one bad cell
never rejects a whole result. It checks every column's cells against its
declared kind and returns at most one Finding per bad column, in column
order: rule is cell_kind, path is a JSON pointer to the first bad
cell (/rows/3/2), tool is None (the caller may replace it), and
message names the column, the kind, the first bad value and the failure
count (column 'size' (big_integer): 1204 of 50000 cells do not fit; first
at row 3: 'n/a'). A None cell always passes, under any kind. An
envelope with no column_kinds has nothing to check and returns [].
A producer asserts its own tabular results fit their declared kinds with one test line:
Each kind accepts:
| Kind | A cell passes when it is |
|---|---|
text |
a str |
number |
an int or a finite float, not a bool |
integer |
an int, not a bool, with magnitude at most 2^53 - 1 (a larger value belongs in big_integer) |
boolean |
a bool |
timestamp |
a str in RFC 3339 date-time form: date, T, time, and a Z or numeric offset |
ip |
a str that ipaddress.ip_address accepts |
fqdn |
a str of dot-separated labels, each 1-63 of letters, digits and - with no leading or trailing -, at most 253 in total, with an optional trailing dot |
mac |
a str of six or eight hex pairs joined consistently by : or - |
ip_block |
a str that is an address, an ip_network(strict=False) prefix, or first-last with both addresses in one family and first <= last |
big_integer |
an int (not a bool), or a str matching ^-?(0\|[1-9][0-9]*)$ |
The same paging fields as the records envelope apply - optional
total_count, next_cursor and stop_reason, and the computed
resumable - with row_count as the number returned; the optional ones
are likewise omitted when absent. TabularEnvelope is published once so a
tool that consumes another tool's table declares its argument as the
model and gets
{"$ref": "#/$defs/TabularEnvelope"} in its input schema instead of
redefining columns and rows:
Paging
truncated says a list was cut short; next_cursor says how to
continue; resumable says whether continuing is possible; stop_reason
says what stopped the read early. A tool that can continue sets
next_cursor to an opaque token and the caller passes it back unchanged
as the tool's cursor argument.
The convention for a paged tool's input is an optional cursor: str | None
(omit for the first page) and limit: int, with the bounds in the
description as usual. A list tool that never continues a read takes no
cursor, even though its envelope schema carries next_cursor, so the
validator does not check the pairing; a producer's own test does, by
passing each next_cursor back as cursor until the last page has none. Cursor contents are private to the tool; a caller never parses
them. There is no offset: cursor paging is modelled on the
nextCursor MCP uses for its own list operations (no protocol defines
paging for a tool's result), stays stable while data is appended, and
asks the model only to echo a token rather than do arithmetic.
resumable is a computed field: true exactly when next_cursor is
present, so the two never disagree. It answers the question truncated
alone does not, whether following the read is possible, so it is
serialized only when truncated is true, where it is always present,
true or false. A complete read's dump leaves it out and has the same
keys as a 1.0 dump, so it still passes a 1.0-built consumer's closed input
schema. A reader treats an absent resumable as false:
truncated |
resumable |
Meaning |
|---|---|---|
true |
true |
There is more; call again with next_cursor as cursor. |
true |
false |
The read ended for good; calling again with the same arguments serves the same items. stop_reason says why. |
false |
absent | A complete read. |
These dump and schema rules apply to the resumable that RecordsEnvelope
and TabularEnvelope define. A subclass that defines its own resumable
computed field keeps the output shape such a subclass had under 1.0: its
dump carries resumable always, true or false, and its output schema
lists it as required. Its input-mode schema declares resumable as an
optional boolean, so its dump passes an envelope-typed closed input schema. model_validate still accepts a resumable key on every
subclass and checks it against next_cursor.
stop_reason is a free string, omitted when the read ended naturally or
the caller's limit simply cut it. It is not a closed vocabulary, so it
has no fallback and a reader shows an unfamiliar value as it stands. The
well-known values include:
stop_reason |
The read stopped because |
|---|---|
run_deadline |
the run's time budget ran out; the items collected so far are returned |
page_count |
it reached the configured cap on source pages per read |
record_limit |
it stopped at a record or row cap; with no next_cursor when the producer cannot resume past it, such as a server-side row cap on a query |
interrupted |
the process running it was interrupted partway |
cursor_stalled |
the source handed back the cursor it was given, so following it would serve the same items again; no next_cursor is published |
Both list envelopes reject a next_cursor or a stop_reason when
truncated is false, and a total_count smaller than the number
returned. A dumped envelope validates again: model_validate (and
read_envelope) accepts a resumable key, checks it against
next_cursor and does not store it, so model_validate(env.model_dump())
round-trips. A resumable that contradicts next_cursor raises in both,
since it is corrupt data, not a newer producer. records_schema(Item) and
tabular_schema() describe the dumped shape: resumable is an optional
boolean property and stop_reason an optional string one. A tool's input
schema that nests an envelope by $ref (input_schema_for on a model
with a TabularEnvelope or RecordsEnvelope field) declares the same
optional resumable, so any dump passes it unchanged under
additionalProperties: false. A tool validates the cursor it
receives like any other argument: the reference implementation rejects a
negative or non-numeric cursor with a ValueError naming cursor.
Decision record OCTL-0002 records the choice.
Warnings on list envelopes
RecordsEnvelope and TabularEnvelope carry warnings: list[str], with
the same meaning as on ArtifactEnvelope: non-fatal problems the caller
should relay, one per entry, beside a result that is still usable. A read
over several sources reports a source that failed or lacked a table this
way instead of failing the whole call. warnings defaults to empty and
the list envelopes leave an empty list out of the serialized JSON, so a
result with no warnings keeps the 1.0 shape; ArtifactEnvelope always
serializes its warnings, empty or not.
{
"columns": ["snapshot", "hosts"],
"rows": [["a", 12]],
"row_count": 1,
"truncated": false,
"warnings": ["snapshot b has no table hosts"]
}
Artifact (result_kind="artifact"), for tools that produce a file:
{
"artifact": { "reference": "reports/assets/a1.svg", "media_type": "image/svg+xml", "bytes": 2048, "width": 640, "height": 360 },
"artifacts": [],
"warnings": [],
"sensitivity": "inherits_input"
}
reference is whatever the injected sink returned; the library never
chooses a filename. A sink in a multi-tenant host returns a reference
relative to the caller's storage root, or an opaque id, never an absolute
host path, so a result never reveals where or for whom it is stored.
role says what the artifact is to the run, such as csv, plan or
summary; it is free text, not a closed vocabulary, so
it has no fallback. uri is an address a client can open to fetch the
artifact when reference is not one it can resolve, such as a resource a
remote host serves; the value is opaque to octools. width, height,
role and uri are omitted when unset,
as is every optional envelope field left at None. artifacts lists
further outputs beside the primary.
sensitivity is "inherits_input" today: per-field extras cannot
annotate a file, so redaction and audit layers treat the artifact as
containing every input column. The vocabulary may gain a value later, but
only one less restrictive than "inherits_input", which stays the
fallback a tolerant reader gives an unrecognized value (see
Reading envelopes from another process).
Source of a result
source says which instance and snapshot answered, so a selector such as
latest can be resolved after the call. It is an optional SourceRef on all
three envelopes, left out of the dump when unset, and both of its strings are
optional. It is a closed object because envelopes nest into strict-clean tool
schemas, which reject an open map; a later minor can add keys, and
read_envelope drops the ones it does not know.
{
"columns": ["name"],
"rows": [["alpha"]],
"row_count": 1,
"truncated": false,
"source": { "instance": "host.demo.invalid", "snapshot": "20260101T000000Z" }
}
A producer in a multi-tenant host keeps source to values the caller may
see, never a host path or another tenant's name. read_envelope drops a
malformed source, and the result stays usable without it.
Sensitivity on record fields
Per-field sensitivity goes on the result model only, as a pydantic
json_schema_extra or a zod .meta() key, under an x- key:
{"x-sensitivity": "identifier" | "free_text" | "none" | "secret"}. The
x- prefix follows OpenAPI's extension rule so no future JSON Schema
keyword collides, and keeping it off input schemas means nothing unknown
is ever sent to a strict-mode API.
The four values are a convention for redaction and audit layers; the
validator does not check them. secret is the most sensitive value: a
redaction or audit layer that meets an x-sensitivity value it does not
recognize treats it as secret, since nothing enforces this convention
and the only safe reading of an unknown marker is the most restrictive
one.
Reading envelopes from another process
A function exported from the package root, for any code parsing an
envelope it did not construct - E is RecordsEnvelope (any
parametrization), TabularEnvelope or ArtifactEnvelope; in TypeScript,
model is TabularEnvelope, ArtifactEnvelope or a schema
RecordsEnvelope(item) returned. data is a Mapping, or a str /
bytes parsed as JSON; in TypeScript, an object, a string, or a
Uint8Array decoded as UTF-8. It validates the same model as
model_validate (.parse), with two relaxations:
- Unknown keys are dropped, at the envelope level and in a nested
octoolsmodel (ArtifactRef). A key insiderecordsis untouched: the record model belongs to the caller, and its own config decides. - Unknown vocabulary values degrade to a fallback.
read_envelopeapplies this to the envelope vocabularies,column_kinds,column_scalesandsensitivity:
| Vocabulary | Unknown value reads as |
|---|---|
column_kinds |
"text" |
column_scales |
None |
sensitivity |
"inherits_input" |
A scale on a column whose kind is non-numeric, whose kind degraded to
"text", or where column_kinds is absent altogether, reads as None
rather than failing the numeric-column check - the scale is a hint about
a numeric kind, and dropping it is always safe once the kind it describes
is unreadable or absent. Every other check still runs: types, required
fields, row widths, the paging rules (a resumable that contradicts
next_cursor included), and the length agreement on column_scales. A
malformed column_descriptions is dropped rather than rejected (see
column_descriptions).
Every closed vocabulary declares a fallback, and every fallback is the
most conservative reading of its vocabulary - a value in a future release
that is more restrictive than a current fallback needs a new major, so a
tolerant reader never treats data as less sensitive, or a tool as less
remote, than it is. Two more fallbacks apply to fields read_envelope
does not touch, because they belong to the OCTool descriptor rather
than an envelope, and octools has no descriptor reader of its own: any
code reading result_kind or access back out of an MCP tool's _meta
applies the same rule.
| Field (source) | Unknown value reads as |
|---|---|
result_kind (_meta) |
"records" |
access (_meta) |
"remote" |
VOCABULARY_FALLBACKS maps the envelope field names column_kinds,
column_scales and sensitivity to their fallbacks.
COLUMN_KIND_FALLBACK ("text"), COLUMN_SCALE_FALLBACK (None),
SENSITIVITY_FALLBACK ("inherits_input"), RESULT_KIND_FALLBACK
("records") and ACCESS_FALLBACK ("remote") are the same five values
by name, for callers that check one field at a time. The x-sensitivity
convention on record fields has the same shape: an unrecognized value
reads as "secret", its most sensitive value (see
Sensitivity on record fields).
Constructing an envelope, or calling model_validate (.parse) on one,
stays strict: an unknown key or vocabulary value raises there, so a
producer's typo still fails in the producer's own tests. read_envelope
raises TypeError when model is not one of the three envelope types.
Tool tags
tags says what a tool is, so a host that merges providers can choose what to
load without knowing any consumer's meta key. TOOL_TAGS lists the
well-known values, and a reader ignores a tag it does not know, so a tool
carrying only unknown tags is never loaded by default. A host still decides
what it loads.
select_tools keeps a tool carrying any given tag, in listing order. It
returns a provider, so listings, converters and validators work on the result.
A deprecated alias whose meta["alias_of"] target is not selected is dropped,
and so is an alias of a dropped alias, so the result passes validate_provider
without a host pruning it. A target is never pulled in.
To require a tag from each of two sets, nest the calls:
A provider may add its own domain tags, such as network, beside the
well-known ones. validate_tags(provider, allowed={"network"})
(validateTags(provider, { allowed: ["network"] })) reports each tag outside
TOOL_TAGS and allowed as unknown_tag; with no allowed it checks against
TOOL_TAGS alone. It is opt-in, and validate_provider does not report tags. A
provider that groups tools with a meta key moves that value to tags.
Provider namespaces
A provider publishes its tools under a namespace: the product's name, matching
NAMESPACE_RE (^[a-z][a-z0-9]{0,15}$), then one underscore, then the tool
name (demo_get_rows). Providers built for one product share its namespace.
A provider keeps short names in its own tables and applies the namespace once
when it builds its listing, with prefix_tools(tools, "demo")
(prefixTools), which also rewrites references to sibling tools in
descriptions; see the converters page.
validate_namespace(provider, "demo") (validateNamespace) checks the
convention:
| Rule | Fires when |
|---|---|
namespace_missing |
the namespace is empty; nothing else is checked |
namespace_pattern |
the namespace breaks NAMESPACE_RE; nothing else is checked |
name_outside_namespace |
a tool's name does not match ^<namespace>_[a-z][a-z0-9_]*$ (demo_, demo__rows and demo_1rows fail); the message says which part |
It is opt-in, and validate_provider does not report namespaces. A consumer
library that requires a namespace runs it in its own conformance check.
Escape conditions
output_schema is preferred, not mandatory. It may be None, or an open
object (additionalProperties: true with the always-present keys listed
in required), only when:
- the tool is a raw pass-through whose keys the caller chooses
(a generic object read with
_return_fields); - the shape depends on a per-instance schema fetched at run time (object-type discovery);
- the source is schema-less (free-form SQL) - use the tabular envelope.
The escape must be explicit: record the reason as a non-empty string in
meta["schema_escape"], or the validator reports
output_schema_escape. That rule fires on a missing schema or a root
with a literal additionalProperties: true; the envelopes are closed
because they forbid extra fields, and a hand-rolled output model should
do the same. When a schema is present the result must conform to it
(MCP's rule). The escape covers output schemas only: an input
schema has no escape because strict-mode model APIs reject an open
object outright, so a free-form options argument must be modelled as a
closed object.
Strict-clean input schemas
Input schemas are sent to model APIs with strict: true, so they must
stay inside the keyword subset both Anthropic and OpenAI accept.
input_schema_for(Model) (inputSchemaFor(schema)) produces such a
schema from a plain pydantic model or zod object; strict_clean(schema)
(strictClean) reports every violation as a Finding with a JSON
pointer path and never mutates the schema. A zod string format such as
z.iso.datetime() also emits a pattern, which strictClean reports;
declare an input format as z.string().meta({ format: "date-time" })
instead.
- Object root;
additionalProperties: falseon every object schema. - Keywords:
type,properties,required,additionalProperties,items,enum,const,anyOf,allOf,$ref,$defs,default,description,format,minItems. Nothing else: notitle, nox-keys, nominimum/maximum/minLength/maxLength/pattern, nooneOf. - Types:
object,array,string,integer,number,boolean,null. Optional fields areanyOf: [{type}, {type: null}]withdefault: nulland omitted fromrequired. - Formats:
date-time,time,date,duration,email,hostname,uri,ipv4,ipv6,uuid. minItemsonly 0 or 1;enumvalues primitives.$refonly to a root$defsentry, never insideallOf, never recursive.
Output schemas are unconstrained 2020-12 (nothing sends them to a grammar
compiler), which is where x-sensitivity and any bounds belong. A
free-form dict field is an open object and therefore not strict-clean;
model it as a closed object or move it out of the input schema.
Versioning and deprecation
SPEC_VERSION is "1.3", the format version of the descriptor and the
envelopes. Under the compatibility contract below, a spec minor is
additive and a spec major is one of the "must not" changes. A reader
checks the major only; a test that pins the whole string fails on every
spec minor. The spec version and the package version are independent: a
release that adds a helper leaves SPEC_VERSION alone.
The compatibility contract
Every release within a major follows one rule for producers and readers: a reader that drops an addition, or reads it as its declared fallback, still reads the rest of the envelope or descriptor correctly. An addition that fails this test needs a new major.
A minor release may add:
- an optional envelope or descriptor field whose absence means exactly what it meant before;
- a vocabulary value whose fallback reading is safe (a
sensitivityvalue only when it is less restrictive than"inherits_input"); - a function, a helper or an export;
- a new
validate_tool,validate_providerorstrict_cleanrule, or a tighter existing one (a smaller strict-clean keyword set, a lower sentence-count ceiling, a stricter name pattern) - shipped opt-in, the wayvalidate_tabulardoes, since a default-on new rule would break a consumer'svalidate_provider(p) == []assertion. Loosening a rule is allowed in a minor without the opt-in requirement; - a key in
to_mcp_toolorto_anthropic_tooloutput (openWorldHint,icons,input_examples), or in a dumped envelope and its schema (the computedresumable) - a consumer asserts on the keys it needs, not on whole-dict equality. An envelope key added in a minor is left out of the dump when it carries nothing new (resumableon a complete read, an empty list-envelopewarnings), so a result that does not use it keeps the older shape and passes an older consumer's closed input schema; - an optional field on
Finding, defaulting toNone.
A release must not, before a new major:
- remove or rename a field, value or export;
- make an optional field required;
- change what an existing field or value means, or what an existing converter output key means, or remove one;
- add a field a reader must understand to read the rest correctly (for example, a flag meaning "rows are deltas" would silently corrupt a tolerant reader);
- add a vocabulary value whose fallback reading is less safe than the
value itself (a
sensitivityvalue more restrictive than"inherits_input"); - tighten a check on parse - an opt-in check such as
validate_tabularis fine, since nothing calls it implicitly; - turn on a new or tightened validator rule by default;
- add a required member to
ToolProvider, which is frozen within a major.
Fields are never renamed or removed within a major; a field that stops
mattering is documented as ignored. The dataclass is keyword-only, so
adding a field to OCTool never breaks construction.
Envelope subclasses. A consumer may subclass a result envelope or
ArtifactRef. The field names role and source belong to octools:
ArtifactRef defines role and every envelope defines source, so a
subclass uses them rather than fields of its own, and no subclass declares
either name. A consumer's own subclass fields carry a consumer prefix (<pkg>_...)
unless the field is being proposed upstream, and a subclass field that
later collides with an octools field is the consumer's to rename or
delete. A subclass that declares its own stop_reason or warnings can
delete them and inherit the RecordsEnvelope and TabularEnvelope
fields, which have the same shape. A subclass that keeps its own
resumable keeps dumping it always, with it required in its output
schema (see Paging).
Public API. The public API of every 1.x release is exactly the
package root's __all__ in Python and the package root's export list in
TypeScript. A name reachable only through a submodule carries no
compatibility promise.
- Renaming a tool: ship the old name as a descriptor with
deprecated=Trueandmeta={"alias_of": "<new name>"}pointing at the samefunc, for at least one major version of the library. - A result change that drops or retypes a field is a new tool name
(
list_objects_v2); there is no per-tool version string.
The 1.x scope fence
Not in scope, by decision, classified by how it can still arrive:
- Additive later, as an optional field or a converter key: timeouts,
cost or latency hints,
input_examples,strict/defer_loadingflags,openWorldHint,icons, translated descriptions. - Additive only as an advisory marker, since runtime policy stays authoritative, the way the MCP safety hints already are: approval class, redaction rules.
- Additive only as a separate optional field, never a change to
funcitself, sincefuncis synchronous by contract: async variants, streaming or progress. - Needs a new major: non-JSON result content.
- Closed, will not be added: per-tool
version- the rename-with- alias path covers it.
The fence is amended only by a new decision record, never by a quiet field.
Declaring an octools dependency
A consuming library declares a range, never a pin:
- The floor is the oldest release that carries every
octoolsname the library uses. It rises only when the library starts using a new name, never just because a release came out. - The ceiling is the next major and nothing tighter: no
==, no~=, no npm tilde (~1.2means< 1.3), and no caret on a0.xversion (^0.3means< 0.4), each of which reintroduces lockstep upgrades between libraries. On npm,^1.2.0and>=1.2.0 <2are the same range. - Because every minor is additive under the contract above, the newest
octoolssatisfies every library's floor at once, so a dependency resolver always finds one version for the whole environment. Two libraries can only conflict across a major. - Any code parsing an envelope it did not construct needs
octools >= 1.0, the first release withread_envelope, and calls it instead ofmodel_validate. - A library that sets
stop_reason, readsresumable, validates a dumped list envelope withmodel_validate, or setswarningson a list envelope orcolumn_descriptionsneedsoctools >= 1.1. - The first
@opscogs/octoolsrelease is 1.2.0, so an npm range starts there whatever names the library uses; the 1.0 and 1.1 floors above apply to the Python package. octoolsholds itself to the same rule for its own dependency:pydantic >= 2.12, < 3in Python, and the peer dependencyzod^4.4.0in TypeScript.
Validator rules
validate_tool and validate_provider (validateTool,
validateProvider) return a list of Finding; each finding has rule,
message, tool and, for schema findings, path. Both packages report
the same rules for the same descriptor.
| Rule | Fires when |
|---|---|
name_pattern |
name does not match the regex |
description_sentences |
fewer than two or more than four sentences |
any strict_clean rule |
input_schema leaves the strict subset; path is prefixed with /input_schema |
output_schema_escape |
output schema missing or open without meta["schema_escape"] |
artifact_envelope |
result_kind="artifact" but the output schema has no artifact property |
deprecated_alias |
deprecated tool without a meta["alias_of"] naming another tool |
alias_target_missing |
the alias names a tool the provider does not list |
unstable_order, duplicate_name |
all_tools() changes order between calls, or lists a name twice |
not_octool |
all_tools() contains something that is not an OCTool |