Skip to content

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.

from octools import merge_providers, validate_provider
import my_tools
import partner_tools

registered = merge_providers(my_tools, partner_tools)
assert validate_provider(registered) == []
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:

{ "records": [{ "...": "keys follow the item schema" }], "count": 12, "truncated": false }

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: log2 draws a log axis ticked at whole powers of two (2^0, 2^32, 2^64, ...); log10 ticks at powers of ten; linear stays 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

def validate_tabular(envelope: TabularEnvelope) -> list[Finding]: ...
function validateTabular(envelope: TabularEnvelope): Finding[];

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:

assert validate_tabular(TabularEnvelope.model_validate(result)) == []
expect(validateTabular(TabularEnvelope.parse(result))).toEqual([]);

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:

class ChartArgs(BaseModel):
    data: TabularEnvelope = Field(description="A table another tool returned.")
    title: str = Field(description="Chart title.")
const ChartArgs = z.object({
  data: TabularEnvelope.describe("A table another tool returned."),
  title: z.string().describe("Chart title."),
});

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.

class HostRecord(BaseModel):
    host: str = Field(
        description="Host name.", json_schema_extra={"x-sensitivity": "identifier"}
    )
    cores: int = Field(description="Number of CPU cores.")
const HostRecord = z.object({
  host: z
    .string()
    .meta({ description: "Host name.", "x-sensitivity": "identifier" }),
  cores: z.int().describe("Number of CPU cores."),
});

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

def read_envelope(model: type[E], data: Mapping[str, object] | str | bytes) -> E: ...
function readEnvelope<O>(model: z.ZodType<O>, data: unknown): O;

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 octools model (ArtifactRef). A key inside records is untouched: the record model belongs to the caller, and its own config decides.
  • Unknown vocabulary values degrade to a fallback. read_envelope applies this to the envelope vocabularies, column_kinds, column_scales and sensitivity:
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.

from octools import TabularEnvelope, read_envelope

table = read_envelope(TabularEnvelope, text)
import { TabularEnvelope, readEnvelope } from "@opscogs/octools";

const table = readEnvelope(TabularEnvelope, text);

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.

from octools import merge_providers, select_tools, to_mcp_tool

merged = merge_providers(my_tools, partner_tools)
default = select_tools(merged, tags={"recommended"})
definitions = [to_mcp_tool(tool) for tool in default.all_tools()]
import { mergeProviders, selectTools, toMcpTool } from "@opscogs/octools";

const merged = mergeProviders(myTools, partnerTools);
const initial = selectTools(merged, { tags: ["recommended"] });
const definitions = initial.allTools().map((tool) => toMcpTool(tool));

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:

selected = select_tools(select_tools(merged, tags={"network"}), tags={"recommended"})
const selected = selectTools(selectTools(merged, { tags: ["network"] }), {
  tags: ["recommended"],
});

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:

  1. the tool is a raw pass-through whose keys the caller chooses (a generic object read with _return_fields);
  2. the shape depends on a per-instance schema fetched at run time (object-type discovery);
  3. 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: false on every object schema.
  • Keywords: type, properties, required, additionalProperties, items, enum, const, anyOf, allOf, $ref, $defs, default, description, format, minItems. Nothing else: no title, no x- keys, no minimum / maximum / minLength / maxLength / pattern, no oneOf.
  • Types: object, array, string, integer, number, boolean, null. Optional fields are anyOf: [{type}, {type: null}] with default: null and omitted from required.
  • Formats: date-time, time, date, duration, email, hostname, uri, ipv4, ipv6, uuid.
  • minItems only 0 or 1; enum values primitives.
  • $ref only to a root $defs entry, never inside allOf, 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 sensitivity value only when it is less restrictive than "inherits_input");
  • a function, a helper or an export;
  • a new validate_tool, validate_provider or strict_clean rule, or a tighter existing one (a smaller strict-clean keyword set, a lower sentence-count ceiling, a stricter name pattern) - shipped opt-in, the way validate_tabular does, since a default-on new rule would break a consumer's validate_provider(p) == [] assertion. Loosening a rule is allowed in a minor without the opt-in requirement;
  • a key in to_mcp_tool or to_anthropic_tool output (openWorldHint, icons, input_examples), or in a dumped envelope and its schema (the computed resumable) - 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 (resumable on a complete read, an empty list-envelope warnings), 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 to None.

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 sensitivity value more restrictive than "inherits_input");
  • tighten a check on parse - an opt-in check such as validate_tabular is 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=True and meta={"alias_of": "<new name>"} pointing at the same func, 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_loading flags, 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 func itself, since func is 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:

# pyproject.toml
dependencies = ["octools >= 1.0, < 2"]
{
  "dependencies": {
    "@opscogs/octools": ">=1.2.0 <2",
    "zod": "^4.4.0"
  }
}
  • The floor is the oldest release that carries every octools name 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.2 means < 1.3), and no caret on a 0.x version (^0.3 means < 0.4), each of which reintroduces lockstep upgrades between libraries. On npm, ^1.2.0 and >=1.2.0 <2 are the same range.
  • Because every minor is additive under the contract above, the newest octools satisfies 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 with read_envelope, and calls it instead of model_validate.
  • A library that sets stop_reason, reads resumable, validates a dumped list envelope with model_validate, or sets warnings on a list envelope or column_descriptions needs octools >= 1.1.
  • The first @opscogs/octools release 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.
  • octools holds itself to the same rule for its own dependency: pydantic >= 2.12, < 3 in Python, and the peer dependency zod ^4.4.0 in 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