Skip to content

feat: full thread support in the MCP (read, list, lifecycle, annotate) — OPIK-7443#152

Merged
awkoy merged 5 commits into
mainfrom
awkoy/opik-7443-read-thread-support
Jul 24, 2026
Merged

feat: full thread support in the MCP (read, list, lifecycle, annotate) — OPIK-7443#152
awkoy merged 5 commits into
mainfrom
awkoy/opik-7443-read-thread-support

Conversation

@awkoy

@awkoy awkoy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Full thread support in the MCP (OPIK-7443)

Makes thread a first-class Opik entity across the MCP tool surface. Before this
PR, threads were only a write/annotate target (and even that was broken for real
threads — see the fix below). Now an agent can read, list, annotate,
and close/reopen a thread through one uniform, self-documenting contract.

The contract (why it's good LLM UX)

One rule everywhere: pass the thread_id string you see in list('thread') /
read('thread'), plus the project (id or name)
. The server absorbs every
backend asymmetry so the model never juggles identifiers:

Surface Tool call
Read a thread as a conversation read('thread', <thread_id>, project_id|project_name){thread, messages, messagesTruncated}; each message = one turn's trace input/output + a trace_id to read('trace', …)
List a project's threads list('thread', project_id|project_name)
Score / comment a thread write score.create / comment.create with target="thread", target_id=<thread_id string> + project
Close / reopen write thread.close / thread.open with {thread_id, project}

What's in here (4 commits)

  • feat(read/list) — thread as a readable/listable entity.
    read('thread', …) returns metadata + the messages list (traces filtered by
    thread_id, up to 200 inlined, sorted ascending). list('thread', project_id=…)
    enumerates a project's threads. New client methods get_thread
    (POST …/threads/retrieve), list_threads, list_traces(filters=…), and a
    _post_json read helper. URI/link parsing: opik://projects/<p>/threads/<t>
    and pasted Opik web thread URLs (looks_like_thread_url). read gains
    project_id/project_name (a thread_id is unique only within a project).

  • feat(write)thread.close / thread.open lifecycle.
    Two write operations toggling thread status (active ↔ inactive), via the
    universal write tool. The dispatcher's _build_request is an explicit
    if name == … chain (no generic fallthrough — a branchless op would hit a
    RuntimeError), so both got a real branch. Verified against opik-backend:
    these are PUT (not POST) and return 204.

  • fix(write) — thread score/comment by thread_id string.
    score.create/comment.create typed target_id as UUID, so annotating any
    real thread failed with uuid_parsing (thread_ids are user-supplied strings).
    Now a shared _AnnotationTarget base validates per target: UUID for
    trace/span (canonicalized), the thread_id string for thread, with a required
    project. For comment, the dispatcher resolves the string thread_id → the
    thread's model UUID (the BE's POST /threads/{id}/comments needs the UUID) so
    the caller never sees the asymmetry.

  • feat(list)project_name for trace/thread lists.
    list('thread'/'trace') now accepts project_name as an alternative to
    project_id, matching every other thread tool and removing a forced
    list('project') UUID round-trip.

Verification

  • Reviewed against opik-backend source for every endpoint/identifier
    (TracesResource @PUT /threads/{open,close}, addThreadComment(@PathParam UUID),
    FeedbackScoreBatchItemThread project fields, the thread_id filter operator).
    Multi-agent adversarial review on each phase caught and fixed real issues
    pre-merge (POST→PUT verb, string-vs-UUID identifiers, a missed schema.json
    snapshot, a silent message-load degrade, non-404 resolve errors bypassing the
    write envelope).
  • Live E2E on dev.comet.com through the built server over MCP stdio:
    create a thread → list (by id and by name) → read (turns in order) →
    score + comment by string id (comment resolves to the model-UUID path) →
    read back (both annotations present) → close (inactive) → open (active).
    All green.
  • ruff + strict mypy (src + tests) clean; 1143 tests pass; conformance
    snapshots (read/list/write/schema) regenerated.

Notes

  • Not in scope (deliberately): destructive thread.delete, feedback-score
    delete, thread tag updates, thread stats. Clean follow-ups.
  • No breaking changes to existing surfaces; trace/span score & comment behavior
    is unchanged (target_id UUID still required and now canonicalized).

🤖 Generated with Claude Code

awkoy and others added 5 commits July 24, 2026 10:49
… (Phase A)

A thread groups traces by thread_id within one project. This lights up the
read + list surfaces (the annotate surface already existed):

- read('thread', id) -> {thread, messages, messagesTruncated}: metadata via
  POST /threads/retrieve + the turns via GET /traces filtered by thread_id.
  Each message carries a trace_id so the agent can read('trace', id) to drill in.
- list('thread', project_id=…) enumerates a project's threads.

Implementation:
- opik_client: get_thread (POST-with-body retrieve), list_threads, list_traces
  filters=, and a _post_json read helper (sibling of _get_json). Protocols widened.
- registry: _fetch_thread / _list_threads / _compress_thread + a needs_project
  flag on EntityHandler (thread only); FetchFn widened to pass project kwargs.
- uri: opik://projects/<p>/threads/<t> + pasted web thread-link parsing
  (looks_like_thread_url), project_id on ParsedURI.
- read_tool: project gate + plumbing; server/read tool gains project_id/
  project_name params (id max_length 512->2048); instructions advertise thread.

Thread reads require project scope (a thread id is unique only within a
project): pass a thread link/URI or project_id/project_name. On a messages
load failure the read degrades to empty messages but surfaces messagesError
rather than silently reporting "no messages".

Reviewed against opik-backend source (endpoints, thread_id filter operator,
TraceThread.id-carries-identifier). ruff + strict mypy + 1107 tests green;
read.json/list.json conformance snapshots regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se C)

Two new write operations let the agent manage a thread's status via the
universal write tool:

- thread.close -> PUT /v1/private/traces/threads/close  (status -> inactive)
- thread.open  -> PUT /v1/private/traces/threads/open   (status -> active)

Both take {thread_id, project_name|project_id} and require the
trace_span_thread_log scope (already in ALL_WRITE_SCOPES).

Implementation:
- models: ThreadClose/ThreadOpen on a shared _ThreadLifecycle base; a
  model_validator requires one of project_name/project_id (the BE's
  TraceThreadIdentifier validator rejects a project-less body with 400 —
  caught locally with a thread_project_missing recovery code).
- registry: two WriteOperation entries (supports_batch=False).
- dispatch: an explicit branch (the dispatcher has no generic fallthrough —
  it ends in raise RuntimeError, so a branchless op would crash). Fixed
  endpoint + exclude_none dump = the wire body verbatim.

Review correction: close/open are PUT (not POST) and return 204 — verified
against opik-backend TracesResource (@put /threads/{open,close}), the
generated Python SDK, and the existing PUT thread-feedback path.

description + schema tools regenerate from the registry; write.json and
schema.json conformance snapshots refreshed. ruff + strict mypy + 1131
tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Threads are keyed by a user-supplied thread_id STRING, but score.create /
comment.create typed target_id as UUID — so annotating any real thread failed
with uuid_parsing. This gives both a single, uniform contract:

  To annotate a thread, pass the thread_id string you see in list('thread') /
  read('thread') as target_id, plus the project. The server does the rest.

- models: shared _AnnotationTarget base — target_id: str, optional
  project_name/project_id, and a model_validator that (a) requires + canonicalizes
  a UUID for trace/span, (b) requires a project for thread (a thread_id is unique
  only within one). ScoreCreate/CommentCreate extend it. target_id description
  spells out the per-target rule.
- dispatch: comment.create on a thread resolves the thread_id string to the
  thread's model UUID via get_thread (the BE's POST /threads/{id}/comments takes
  the model UUID), so callers never juggle two identifiers. score.create thread
  keeps sending the string thread_id. trace/span bodies stay minimal.

Review-driven hardening: non-404 backend errors during the resolve surface as a
structured BackendError (not a raw OpikError that bypasses the JSON envelope);
dropped a wrong id fallback; canonicalize non-dashed UUIDs so a locally-valid id
is BE-valid; truncate=True on the resolve fetch; dry_run notes that a thread
comment's path resolves at execution.

Verified against opik-backend source (addThreadComment @PathParam UUID,
FeedbackScoreBatchItemThread project fields) and live e2e on dev: score + comment
a thread by its string id, both read back on the thread. ruff + strict mypy +
1139 tests green; no conformance drift (per-op model schemas aren't snapshotted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
list('thread') / list('trace') required project_id (a UUID), while every other
thread tool (read/score/comment/close/open) also accepts project_name — so an
LLM with only a project name had to make an extra list('project') call first.
Now the list tool takes project_name as an alternative:

- run_list gains project_name; forwarded only for project-scoped lists
  (project_id in list_required_kwargs) so it can't reach a workspace-wide
  list_fn as an unexpected kwarg. The required-kwarg check accepts
  project_name in lieu of project_id, and the error names both.
- server.py list tool exposes project_name (mirrors read); docstring updated.

The client methods and OpikListClient Protocol already accept project_name, so
this is purely surfacing it. ruff + strict mypy + 1143 tests green; list.json
conformance snapshot regenerated (adds the project_name param).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`make lint` runs `ruff format --check`; three files from the thread work had
formatting drift (only `ruff check` was run locally). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@awkoy
awkoy force-pushed the awkoy/opik-7443-read-thread-support branch from b4a4739 to 5e677a2 Compare July 24, 2026 08:50
@awkoy
awkoy merged commit 3b2f8b6 into main Jul 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants