Merge fix/narrow-late-identity: second identity run only for an empty answer or a missing late key
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
Tests / test (==2.12.2) (push) Canceled after 0s
Tests / test (==2.14.3) (push) Canceled after 0s
Tests / test (>=3.3,<4) (push) Canceled after 0s
Tests / test (>=4,<5) (push) Canceled after 0s
Tests / test-postgres (==2.12.2) (push) Canceled after 0s
Tests / test-postgres (>=4,<5) (push) Canceled after 0s
Tests / canary (push) Canceled after 0s
This commit is contained in:
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Calendar Versioning](https://calver.org/) (YYYY.MM.DD).
|
||||
|
||||
## [2026.10.02.1] - 2026-10-02
|
||||
|
||||
The identity resolver's second run is opt-in again for any resolver that
|
||||
answers.
|
||||
|
||||
### Changed
|
||||
- The second resolver run after the tool now happens only when the first
|
||||
answer was empty (no keys, only None values, or a run that failed or timed
|
||||
out) or lacks a key in `late_identity_keys`. In 2026.10.02 a missing
|
||||
`user_sub`, `caller_kind` or `client_id` also triggered it, so a resolver
|
||||
that never returns one of them (mcspeak's returns no `client_id`) ran twice
|
||||
on every kept call. `late_identity_keys` stays empty by default: a resolver
|
||||
that answers anything runs once per call unless the server opts in.
|
||||
blender-mcp's registration fix still needs
|
||||
`late_identity_keys=["bus_client_uuid"]`, as in 2026.10.02.
|
||||
|
||||
## [2026.10.02] - 2026-10-02
|
||||
|
||||
A tool that creates its caller's identity records it on its own row again.
|
||||
|
||||
@@ -391,10 +391,13 @@ beforehand whether a call will fail). It does not run in mode `off` or for
|
||||
the result.
|
||||
|
||||
Some tools create the identity they run under: a registration that makes the
|
||||
client record the resolver looks up, or a login. When the first answer is
|
||||
incomplete, meaning `user_sub`, `caller_kind` or `client_id` is missing, or
|
||||
any key named in `late_identity_keys` is, the resolver runs a second time
|
||||
after the tool, alongside the enricher, and only for calls that are kept.
|
||||
client record the resolver looks up, or a login. The resolver runs a second
|
||||
time after the tool, alongside the enricher and only for calls that are kept,
|
||||
when its first answer was empty (nothing, only None values, or a run that
|
||||
failed or timed out) or lacks a key named in `late_identity_keys`. A missing
|
||||
`user_sub`, `caller_kind` or `client_id` alone does not trigger it, and
|
||||
`late_identity_keys` is empty by default, so a resolver that answers anything
|
||||
runs once per call unless you opt in.
|
||||
The call's own row takes the second answer's values where the first had
|
||||
none; where both have a value, the first wins, so the row agrees with the
|
||||
events recorded during the call. Those events are not rewritten. A complete
|
||||
@@ -413,8 +416,8 @@ def who(context):
|
||||
instrument(app, identity_resolver=who, late_identity_keys=["bus_client_uuid"])
|
||||
```
|
||||
|
||||
Without `late_identity_keys` this resolver's first answer is complete (it has
|
||||
all three columns), so the call that registers the client would be recorded
|
||||
Without `late_identity_keys` this resolver's first answer is not empty, so
|
||||
there is no second run and the call that registers the client is recorded
|
||||
without `bus_client_uuid`.
|
||||
|
||||
### Linking feedback to the calls behind it
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fastmcp-feedback"
|
||||
version = "2026.10.02" # YYYYMMDD.patch versioning - update for each release
|
||||
version = "2026.10.02.1" # YYYYMMDD.patch versioning - update for each release
|
||||
description = "Production-ready feedback collection system for FastMCP servers"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -41,7 +41,7 @@ Quick Start:
|
||||
import logging
|
||||
|
||||
# Version information - CalVer YYYY.MM.DD
|
||||
__version__ = "2026.10.02"
|
||||
__version__ = "2026.10.02.1"
|
||||
__author__ = "Ryan Malloy"
|
||||
__email__ = "ryan@supported.systems"
|
||||
|
||||
|
||||
@@ -144,13 +144,16 @@ class InstrumentationMiddleware(Middleware):
|
||||
the tool, so events recorded during the call get that identity;
|
||||
it also runs for calls that sampling later drops, and an async
|
||||
one can delay the tool by up to ``hook_timeout``; keep it cheap.
|
||||
When that first answer is incomplete (any of the three columns
|
||||
missing, or any of ``late_identity_keys``), it runs again after
|
||||
the tool, alongside the enricher, and the call's own record
|
||||
takes the second answer's values where the first had none.
|
||||
When that first answer is empty (nothing, or only None values,
|
||||
including a run that failed or timed out) or lacks any of
|
||||
``late_identity_keys``, it runs again after the tool, alongside
|
||||
the enricher, and the call's own record takes the second
|
||||
answer's values where the first had none. Any other answer means
|
||||
one run, even without all three columns.
|
||||
late_identity_keys: identity keys that may only exist once the tool
|
||||
has run, such as an id a registration or login tool creates. A
|
||||
first answer without one of them gets the second run.
|
||||
first answer without one of them gets the second run. Empty by
|
||||
default.
|
||||
enricher: ``(tool, args, result, context) -> dict`` of extra fields,
|
||||
stored in ``extra``. A ``server_version`` key fills that column.
|
||||
May be async.
|
||||
@@ -365,8 +368,12 @@ class InstrumentationMiddleware(Middleware):
|
||||
"""Whether the resolver's first answer lacks something it may know later."""
|
||||
if self.identity_resolver is None:
|
||||
return False
|
||||
return any(not who.column(k) for k in _IDENTITY_COLUMNS) or any(
|
||||
who.rest.get(k) is None for k in self.late_identity_keys
|
||||
answered = any(
|
||||
v is not None for v in (*who.columns.values(), *who.rest.values())
|
||||
)
|
||||
return not answered or any(
|
||||
who.rest.get(k) is None and who.columns.get(k) is None
|
||||
for k in self.late_identity_keys
|
||||
)
|
||||
|
||||
async def _call_hook(self, hook: Callable | None, *args: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -119,12 +119,14 @@ class TestRegistration:
|
||||
assert ev.call_id == rec.id and columns(ev) == ("user-1", "addon", "dcr-9")
|
||||
|
||||
async def test_blender_shaped_without_late_keys_runs_once(self):
|
||||
# The three columns are all there before the tool, so nothing marks
|
||||
# the answer incomplete: the bus keys need late_identity_keys.
|
||||
# The first answer is not empty, so without late_identity_keys there
|
||||
# is no second run and the row lacks the bus keys. Opt-in by design.
|
||||
app, sink, mw, who = make_app(BlenderResolver)
|
||||
await call(app, mw, ("register_client", {"client_uuid": "c-1"}))
|
||||
[rec] = sink.calls
|
||||
assert rec.identity == {} and who.calls == 1
|
||||
assert rec.identity == {} and "bus_client_uuid" not in rec.identity
|
||||
assert columns(rec) == ("user-1", "addon", "dcr-9")
|
||||
assert who.calls == 1
|
||||
|
||||
async def test_registered_caller_runs_once(self):
|
||||
app, sink, mw, who = make_app(BlenderResolver, late=("bus_client_uuid",))
|
||||
@@ -176,7 +178,9 @@ class TestMerge:
|
||||
]
|
||||
)
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=lambda c: next(answers)
|
||||
EmptyUntilRegistered,
|
||||
identity_resolver=lambda c: next(answers),
|
||||
late=("team",),
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
@@ -205,7 +209,9 @@ class TestFailures:
|
||||
return {"user_sub": "u1"}
|
||||
raise RuntimeError("resolver bug")
|
||||
|
||||
app, sink, mw, _ = make_app(EmptyUntilRegistered, identity_resolver=who)
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=who, late=("bus_client_uuid",)
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
assert rec.ok and rec.user_sub == "u1" and len(runs) == 2
|
||||
@@ -221,14 +227,43 @@ class TestFailures:
|
||||
return {"user_sub": "late", "caller_kind": "k"}
|
||||
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=who, hook_timeout=0.05
|
||||
EmptyUntilRegistered,
|
||||
identity_resolver=who,
|
||||
hook_timeout=0.05,
|
||||
late=("bus_client_uuid",),
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
[rec] = sink.calls
|
||||
assert rec.ok and rec.user_sub == "u1" and rec.caller_kind is None
|
||||
assert len(runs) == 2
|
||||
|
||||
|
||||
class McspeakResolver:
|
||||
"""mcspeak's _who: user_sub and caller_kind, never client_id."""
|
||||
|
||||
def __init__(self, bus):
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, context):
|
||||
self.calls += 1
|
||||
return {"caller_kind": "claude-code", "user_sub": "mctalkbox"}
|
||||
|
||||
|
||||
class TestWhenItRuns:
|
||||
async def test_missing_column_alone_does_not_rerun(self):
|
||||
app, sink, mw, who = make_app(McspeakResolver)
|
||||
await call(app, mw, ("ping", {}), ("quiet", {}), ("ping", {}))
|
||||
assert who.calls == 3 and len(sink.calls) == 3
|
||||
assert {columns(r) for r in sink.calls} == {("mctalkbox", "claude-code", None)}
|
||||
|
||||
async def test_only_none_values_count_as_empty(self):
|
||||
answers = iter([{"user_sub": None}, {"user_sub": "u2", "caller_kind": "k"}])
|
||||
app, sink, mw, _ = make_app(
|
||||
EmptyUntilRegistered, identity_resolver=lambda c: next(answers)
|
||||
)
|
||||
await call(app, mw, ("ping", {}))
|
||||
assert columns(sink.calls[0])[:2] == ("u2", "k")
|
||||
|
||||
async def test_complete_answer_runs_once(self):
|
||||
app, sink, mw, who = make_app(BlenderResolver)
|
||||
await call(app, mw, ("ping", {}), ("ping", {}))
|
||||
|
||||
Reference in New Issue
Block a user