The tool surface is what every host session loads from this server: the
read, list and schema tools, the initialize instructions, and the wire
contract the conformance suite pins. write is in
writes and read_skill in
skills. This doc answers what a host receives, what
a read or a list promises, what is refused, and which test holds each promise.
read, list, write, schema and read_skill, each with a
title and all four hints. Only write is destructive (READS, WRITES
in src/opik_mcp/server/tools/hints.py).structured_output=False everywhere: one text copy per answer.SURFACE_BUDGET_BYTES,
INSTRUCTIONS_BUDGET_BYTES in tests/conformance/test_tool_inventory.py),
with each raise recorded beside them. Input schemas are frozen in
tests/conformance/snapshots/ and change only with UPDATE_SNAPSHOTS=1.Claude Code cuts a description or the instructions at DESCRIPTION_LIMIT
without warning. Tools over it today are strict expected failures in
OVER_THE_LIMIT (tests/conformance/test_tool_annotations.py).
initialize returns the name opik-mcp and the text of render_instructions
(src/opik_mcp/instructions.py): workspace and UI address, the default
project if set, tool selection, the link rule, and today’s UTC date.
Comet-Workspace header, then the name
introspected from an OAuth bearer, then the configured workspace, then
"default" (current_workspace in src/opik_mcp/read_list/ui_links.py).opik_default_project_name) reaches the agent only
here, as a name. Tools keep no state and never fall back to it, so the
agent passes project_name on every call.install_session_instructions, which
renders it per session to name that session’s OAuth workspace, and keeps
the boot text if a render fails.read(entity_type, id, project_id?, project_name?, since?, until?, fields?):
[read: trace <id> | 1,234 tok | open as a link named 'my-trace']
{"trace": {...}, "spans": [...], "spansTruncated": false, "spanBodies": "...", "url": "..."}
size_header, _CHARS_PER_TOKEN
in src/opik_mcp/read_list/size.py) and, when there is a url, the link
text to use.id takes a UUID, a name (project, experiment, prompt, dataset), an
opik:// URI or a pasted Opik link: each entity declares its uri_patterns,
and parse in src/opik_mcp/read_list/uri.py tries them by uri_precedence. A
URI or link overrides entity_type, and a project-scoped one the project.
Several name matches are refused with the candidates. No match falls through
to a 404.read_window. The order is in run_read
(src/opik_mcp/read_list/read_tool.py).trace: spans cut by the backend’s truncate=true, bodies dropped past
SPANS_INLINE_CHARS (the first span keeps its body), spanBodies, and
moreSpans past SPANS_INLINE_LIMIT.thread: one message per trace, sorted by start_time, same body budget,
and messagesError when the traces call fails.prompt: versions up to VERSIONS_INLINE_LIMIT, then moreVersions.fields=[…] returns only the named dotted paths, uncut, and always keeps
the id. It is marked | projected in the header and on the line under it.
An unknown path is refused with the valid ones._raise_for_status in src/opik_mcp/client/base.py). A missing record
adds the list('<type>', …) call for a listable type
(_format_client_error). A 400 or 422 ends with Backend said: "…": the
strings under the body’s errors or message, on one line, cut at
_BACKEND_REASON_CHARS (backend_reason). No other part of the body, and
no REST path, reaches a refusal.[list: trace | 1,234 tok | filters: error_info is_not_empty AND source = "sdk" | sort: duration desc | since: 1h (2026-09-24T10:03Z)]
list_size_header in src/opik_mcp/read_list/size.py). A runner’s
answer gets it put into the [list: …] line it wrote (with_list_size).filters is OQL, the grammar of the SDK’s search_traces(filter_string=…),
parsed by src/opik_mcp/read_list/oql_parser.py and checked in
src/opik_mcp/read_list/oql.py against the entity’s Vocabulary
(src/opik_mcp/read_list/handler.py) and the backend’s tables in
src/opik_mcp/read_list/oql_fields.py. All problems in a string come
back in one OQLError; an unknown field gets the closest name and the valid
ones (test_unknown_field_suggests_the_closest_name_and_lists_the_fields).
Checked clauses go JSON-encoded in the filters query parameter, except
the vocabulary’s param_fields, which become parameters of their own.
schema("list.<entity>") lists fields, operators and sortable names.sort is one field, asc or desc. since/until take 30m, 1h,
7d, 2w or an ISO-8601 instant with a zone. Only trace, span and thread
take a window or search; other types refuse both and name what works.project_id or project_name. A misspelled
name gets the closest project name back: on a 404 the projects endpoint is
asked whether the name exists (_refuse_unknown_project).project_metric, the dataset comparison) takes the call from run_list.list_extra_fields, then the sort and filter fields.
Cells past _TRUNCATE_AT are cut and counted; url cells never are.
fields=[…] picks the columns and lifts the cut.list('thread') leaves out
first_message, and fields=["first_message"] still returns it.run_list (src/opik_mcp/read_list/list_tool.py);
the argument checks are in resolve_list_args (src/opik_mcp/read_list/list_args.py).An empty page with a zero total carries at most one hint, picked by
empty_message (src/opik_mcp/read_list/list_empty_page.py) in this order: the entity’s page_note_fn note
(Diagnostics); with since, the project’s last trace when it is before the
window; under the sdk default, the rows other sources hold; with name, the
rows without it; with filters, the rows without them. A failed probe is
skipped.
A read carries a url, a url_absent sentence when the record has no page,
or nothing when the UI base or workspace is unknown. A trace, span or thread
page carries one url_template filled from each row: the entity’s
row_link_template hook, turned into a page note by page_note_of in
src/opik_mcp/read_list/decorations.py. An entity with no page of its own
declares a view_page, and a case or prompt version a parent_page.
server/tools/ read / list (FastMCP tool, instrument_tool wrapper)
-> read_list/read_tool.py run_read | read_list/list_tool.py run_list
-> read_list/registry.py ENTITY_REGISTRY[entity_type] (an EntityHandler)
-> read_list/entities/<entity>.py fetch_fn / list_fn / run_fn / link_fn / page_note_fn
-> client/ -> Opik REST API
schema("list.*") goes writes/schema_tool.py run_schema → read_list/reference.py list_reference.
Where to start:
filter_fields and sort_fields
of the Vocabulary in src/opik_mcp/read_list/entities/trace.py, mirroring
opik-backend’s TraceField enum and TraceSortingFactory (span and thread
have their own). A field the backend takes as a query parameter also goes in
param_fields. The registry indexes vocabularies by name (VOCABULARIES),
and the schema reference follows by itself.HANDLER (EntityHandler in src/opik_mcp/read_list/handler.py)
in read_list/entities/, registered in src/opik_mcp/read_list/registry.py.src/opik_mcp/read_list/list_table.py. An empty-page hint:
src/opik_mcp/read_list/list_empty_page.py. An argument check:
src/opik_mcp/read_list/list_args.py.Root modules stay generic (ADR 0004);
entity_names_at_root in tests/repo/ratchets.json is empty and stays so: a root table
for one entity is a missing hook on EntityHandler.
decorations.block runs an optional part of a read under a deadline. A
failure or timeout becomes {"error": "Could not load …"} and the read still
returns. Its main user is project-overview.
Boundaries:
client_for_call and the longer search timeout:
runtime.instrument_tool and the _*_props functions:
analytics. It buckets failures by the
EntityArgValidationError subclass, so every argument error raises one.project_scope.py: diagnostics.fields=[…] filters and always declares itself (#187, #197,
ADR 0002).source = "sdk", like the Logs page, so
evaluator, playground and experiment traffic does not crowd out the
application. A filter on a parent id (PARENT_ID_FIELDS) turns it off:
experiment traces never have source sdk, so the default hid a whole
drill-in. The metric runner follows the same rule.search on a type without it is refused. It used to return an unfiltered
page that an agent read as the result (_search_refusal).structuredContent doubled every string
on the wire. Links are never guessed (#201).errors/message strings of a 400 or 422, labelled as the
backend’s, because a validation reason is what makes the retry right
(OPIK-8496).first_message left the default columns because a table never
echoes a trace body; rows stay distinct by id, status, size and time
(OPIK-8496)._meta["anthropic/maxResultSizeChars"]
(open in ADR 0002).read and list
docstrings. Only the byte budget and the description limit check those.project_id, or whose span call fails, comes back with
empty spans and no notice.from_time/to_time are UUIDv7 bounds on the record id, so a list window
filters on creation time. An exact start_time bound goes in OQL.sortable_by; the header then calls the page unsorted.project_rows in
src/opik_mcp/read_list/project_scope.py). With only a project_id, a
project outside that page gets the bare “No traces found.”link_workspace returns
None and project-scoped links are left out, because the configured
workspace would point into the wrong one.project_page_url, which accepts only ProjectArea.tests/conformance/,
test_no_entity_resources_advertised.tests/server/test_instructions.py; per session,
test_initialize_names_oauth_workspace; agreeing with tools/list,
tests/hermetic/test_stdio_session.py.tests/read_list/test_read_tool.py; the uncut
record, test_a_huge_record_comes_back_whole.sdk default and empty-page hints:
tests/read_list/test_list_tool.py, tests/read_list/test_list_filters.py;
the size on every page, test_a_page_states_its_size_on_the_first_line,
test_a_series_states_its_size_on_the_first_line; no thread body,
test_a_thread_page_does_not_echo_the_first_message_body.test_a_refused_read_carries_neither_the_backend_body_nor_the_rest_path
and the backend-reason cases in tests/client/test_read.py
(test_a_400_quotes_the_backends_error_strings_after_the_fix,
test_the_quoted_backend_text_is_capped,
test_the_quoted_backend_text_is_one_line_without_double_quotes,
test_only_a_400_or_422_quotes_the_backend).fields: tests/read_list/test_oql.py,
tests/read_list/test_list_schema.py, tests/read_list/test_fields.py.tests/read_list/test_link_shape.py, tests/read_list/test_ui_links.py,
and each entity’s file in tests/hermetic/reads/, which also checks the size
header and that a refusal carries no backend body or REST path
(tests/hermetic/reads/answers.py).tests/read_list/test_modular.py,
tests/hermetic/test_description_claims.py.first_message left list('thread'); refusals lost the backend body and path, keeping a capped reason on 400/422 (OPIK-8496).read_skill names skills only; each SKILL.md footer lists its references (OPIK-8496).structuredContent copy removed (#201).fields=[…] on read and list, so the caller names what comes back (#197).filters, sort, since/until and search on list, so one call answers most questions (#185).read_skill added; the instructions stopped describing an unadvertised tool (#175).