Skip to content

feat(mcp): an agent can read a measurement, not just configure one - #524

Merged
tylerkron merged 2 commits into
mainfrom
feat/mcp-live-data-tools-498
Aug 13, 2026
Merged

feat(mcp): an agent can read a measurement, not just configure one#524
tylerkron merged 2 commits into
mainfrom
feat/mcp-live-data-tools-498

Conversation

@tylerkron

Copy link
Copy Markdown
Contributor

What was wrong

The MCP server shipped fifteen tools and not one of them could answer "what is the voltage on AI0?". An agent could find a device, connect to it, enable channels, set the sample rate, drive DIO and PWM, and start an SD recording — and then had to wait for the recording to finish and download a file to see a single number. Reading data is the whole point of a DAQ, and it was the one thing the agent could not do.

How it was fixed

Two tools, both reading the live-sample stream Core already had:

  • read_channel_values — the latest value on every enabled channel, with the timestamp it was sampled at. It returns as soon as every channel has reported (about 100 ms on the bench), not when its timeout expires, and a channel that said nothing comes back null rather than 0.
  • capture_samples — a bounded block of data as rows: one row per sample tick, one column per channel (AI0, DIO3). It ends on whichever budget runs out first, the duration or the row count, and reports what it actually got: the rate achieved, the rate the device's own clock claims, and how many samples were dropped.

Both start the device's stream only if nothing is streaming yet and stop it again afterwards — a session the caller already had running is read and left alone — and both are refused while the device is recording to its SD card, because a card recording routes the data away from this machine and a capture would just wait out its window and return nothing.

Three things a reviewer may want to push back on:

  • ILiveSampleSource instead of putting the members on IStreamingDevice. The issue asked for the promotion onto IStreamingDevice; that interface is public and implementable outside the library, so adding abstract members to it breaks every external implementer. A capability interface gets the same benefit — a typed consumer reads live data without naming DaqifiStreamingDevice — and is additive. ISdCardOperations is exactly this shape already.
  • A capture holds the device exclusively for its whole window (up to 60 s), so a concurrent tool call on the same device waits. That is deliberate: a configure_* landing mid-capture would change the channel set the columns are aligned to, and the rows would silently stop meaning what they say.
  • Two rate numbers. measuredRateHz is this machine's clock, deviceClockRateHz is the device's own timestamps. Either alone can only say "slower than requested"; the two disagreeing is what identifies a device clock that is not keeping real time — which is exactly what this bench unit does (789-796 Hz measured against a device clock insisting on 1000 Hz, firmware #716).

Verification

Tests: +37 (Core 3135 → 3137, MCP 86 → 123). Full suite green on net9.0 + net10.0, 0 warnings. The MCP tests cover the grouping rules that shape the data — including a channel reporting twice under one timestamp starting a new row rather than overwriting, which is what firmware 3.7.2 does at high rates — plus the drain's stop conditions and the --read-only rule.

Bench (real Nyquist, firmware 3.7.2, USB, non-destructive — streaming and SCPI only): 24/24 checks. The issue's criterion, 4 channels at 1 kHz for 10 s, returned 7863 rows / 31,452 samples with 0 dropped and 0 rows missing from the sequence the device's own clock accounts for. A mixed analog+digital capture came back with columns AI0..AI3, DIO0, DIO1 in that order, and both tools left the device not streaming.

Two things the bench changed in the code: the minimum budgets are now 500 ms (read) and 250 ms (capture), because a device that is not streaming yet sends nothing for the first 85-110 ms and a 100 ms budget reported a healthy device as silent; and measuredRateHz is timed from the first sample rather than from the call, so that start-up wait is not charged to the device as a lower rate.

closes #498

Not merging — this is for your review.

The MCP server had fifteen tools and no way to answer "what is the
voltage on AI0?" — an agent could discover, connect, configure channels,
drive DIO/PWM and start an SD recording, but the data only ever came
back afterwards as a file. Two tools close that: read_channel_values
takes a spot reading of every enabled channel, and capture_samples
returns a bounded block of live data as timestamp-aligned rows.

Both attach to Core's existing live-sample stream, start the device's
stream only if nothing is streaming yet (and stop it again afterwards),
and are refused while the device is recording to its SD card — a card
recording routes the data away from this machine, so a capture would
wait out its window and return nothing.

Core gains ILiveSampleSource, a capability interface over the live
stream it already had, so a consumer holding a device can read data
without naming DaqifiStreamingDevice itself. Deliberately additive
rather than new members on IStreamingDevice, which is public and
implementable outside the library; ISdCardOperations is the same shape.

closes #498
@tylerkron
tylerkron requested a review from a team as a code owner August 13, 2026 21:26
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

feat(mcp): add live measurement tools (read values + capture samples)

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add MCP tools to read latest channel values and capture bounded live sample blocks.
• Introduce an additive Core capability interface for consuming decoded live samples.
• Enforce safe stream ownership/locking semantics and document SD-logging incompatibility.
Diagram

graph TD
  Tools["MCP tools"] --> Agent["DaqifiAgent"] --> Runner["RunLiveCapture"] --> Capture["LiveSampleCapture"] --> Live["ILiveSampleSource"] --> Dev["DaqifiStreamingDevice"] --> Stream["IStreamingDevice"]
  Runner --> Sink["Sinks (Latest/Rows)"]
  Agent --> Dtos["Live DTOs"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add live members to IStreamingDevice
  • ➕ Single interface to check for “streaming + live sample access”
  • ➕ Fewer types for consumers to reason about
  • ➖ Breaking change for external implementers of the public interface
  • ➖ Forces all streaming devices to implement live-sample surface even if not supported
2. Use default interface methods on IStreamingDevice (if available)
  • ➕ Could avoid breaking implementers while keeping one interface
  • ➕ Allows gradual adoption by implementers
  • ➖ Requires C#/.NET feature compatibility across all consumers
  • ➖ Still expands the public surface area and can complicate versioning/tooling expectations
3. Move capture/spot-read utilities into Daqifi.Core
  • ➕ Reusable outside MCP (any typed consumer gets high-level live reads)
  • ➕ Keeps MCP thinner by delegating policy-free capture logic to Core
  • ➖ Increases Core’s responsibility (policy knobs like windows/row budgets may not belong there)
  • ➖ May require additional Core DTOs or abstractions to keep it transport-agnostic

Recommendation: Keep the PR’s current strategy: an additive ILiveSampleSource capability interface in Core plus MCP-level tooling built on a shared drain/sink engine. This avoids breaking external IStreamingDevice implementers, preserves a typed path to live samples, and centralizes tricky stream-ownership + cancellation semantics in one place while keeping Core’s existing stream pipeline intact.

Files changed (10) +1352 / -6

Enhancement (6) +769 / -1
DaqifiStreamingDevice.csDeclare ILiveSampleSource capability on DaqifiStreamingDevice +1/-1

Declare ILiveSampleSource capability on DaqifiStreamingDevice

• Updates the Core streaming device class to implement ILiveSampleSource, enabling typed consumers to read live samples without referencing the concrete class by name.

src/Daqifi.Core/Device/DaqifiStreamingDevice.cs

ILiveSampleSource.csIntroduce ILiveSampleSource capability interface for live decoded samples +39/-0

Introduce ILiveSampleSource capability interface for live decoded samples

• Adds a new interface exposing StreamSamplesAsync and DroppedLiveSampleCount, explicitly positioned as an additive capability (similar to ISdCardOperations) to avoid breaking public IStreamingDevice implementers.

src/Daqifi.Core/Device/ILiveSampleSource.cs

DaqifiAgent.csImplement live-data tool APIs with exclusive capture and stream ownership +298/-0

Implement live-data tool APIs with exclusive capture and stream ownership

• Adds ReadChannelValuesAsync and CaptureSamplesAsync built on a shared RunLiveCaptureAsync that locks the device, snapshots enabled channels, optionally starts/stops streaming, refuses operation during SD logging, clamps time/row budgets, and reports dropped/ignored samples plus rate metrics.

src/Daqifi.Mcp/DaqifiAgent.cs

Dtos.csAdd DTOs for live channel readings and sample captures +116/-0

Add DTOs for live channel readings and sample captures

• Introduces response models for spot readings (ChannelReadings/ChannelReading) and bounded captures (CaptureResult/CaptureRow), including null semantics for missing data and dual rate metrics (measured vs device-clock).

src/Daqifi.Mcp/Dtos.cs

LiveSampleCapture.csAdd shared live capture engine and two sink implementations +296/-0

Add shared live capture engine and two sink implementations

• Implements LiveSampleCapture.DrainAsync for windowed draining with correct subscription/start ordering and robust exception handling, plus LatestValueSink and SampleRowSink to shape the stream into either latest-per-channel readings or timestamp-aligned rows with row budgets and unexpected-channel accounting.

src/Daqifi.Mcp/LiveSampleCapture.cs

DaqifiTools.csExpose read_channel_values and capture_samples as MCP tools +19/-0

Expose read_channel_values and capture_samples as MCP tools

• Adds two MCP tool entrypoints with detailed descriptions, parameter defaults/clamping notes, and behavior around null values, early return, stream start/stop ownership, read-only refusal conditions, and exclusive capture semantics.

src/Daqifi.Mcp/Tools/DaqifiTools.cs

Tests (2) +562 / -0
DaqifiStreamingDeviceLiveStreamTests.csAdd tests proving ILiveSampleSource works via interface reference +42/-0

Add tests proving ILiveSampleSource works via interface reference

• Adds coverage ensuring the live stream can be consumed through ILiveSampleSource (not via concrete casts) and that DroppedLiveSampleCount is observable through the capability interface.

src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceLiveStreamTests.cs

LiveDataToolsTests.csAdd contract tests for read_channel_values and capture_samples behavior +520/-0

Add contract tests for read_channel_values and capture_samples behavior

• Introduces extensive tests covering read-only semantics, window clamping, stream-start ordering, early completion, timeout behavior, cancellation/failure propagation, and data shaping into latest-values and timestamp-aligned rows.

src/Daqifi.Mcp.Tests/LiveDataToolsTests.cs

Documentation (2) +21 / -5
Daqifi.Mcp.csprojUpdate package description to include live measurement reading +1/-1

Update package description to include live measurement reading

• Expands the NuGet/package description to advertise that the MCP server can read live measurements in addition to configuring and SD logging.

src/Daqifi.Mcp/Daqifi.Mcp.csproj

README.mdDocument new live tools and read-only / SD-logging constraints +20/-4

Document new live tools and read-only / SD-logging constraints

• Updates the README to list the new tools, explain when to use live capture vs SD logging, describe stream ownership behavior, and clarify read-only mode behavior for live reads.

src/Daqifi.Mcp/README.md

@qodo-code-review

qodo-code-review Bot commented Aug 13, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Row limit misreported ✓ Resolved 🐞 Bug ≡ Correctness
Description
CaptureSamplesAsync sets rowLimitReached based on rows.Count after calling
SampleRowSink.Complete(), but Complete() can flush an in-progress row when the time window
elapses. This can make rowLimitReached true even though the capture stopped due to duration,
misleading callers about why the capture ended.
Code

src/Daqifi.Mcp/DaqifiAgent.cs[R1106-1109]

+            RateHz(rows.Count, run.Outcome.DataElapsed),
+            RateHz(rows.Count, rows.Count > 1 ? rows[^1].Timestamp - rows[0].Timestamp : TimeSpan.Zero),
+            rows.Count >= rowBudget,
+            run.Channels.Select(c => c.Label).ToList(),
Relevance

●●● Strong

Team has accepted fixes clarifying misleading tool results/docs; likely to fix RowLimitReached
semantics too.

PR-#475
PR-#470

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
CaptureSamplesAsync derives rowLimitReached from rows.Count after Complete(). But
SampleRowSink.IsComplete only considers _rows.Count (closed rows), and Complete() will close
_open when !IsComplete, potentially increasing the returned row count to the budget even if the
budget did not stop the capture.

src/Daqifi.Mcp/DaqifiAgent.cs[1084-1110]
src/Daqifi.Mcp/LiveSampleCapture.cs[230-281]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`capture_samples` currently computes `rowLimitReached` using `rows.Count >= rowBudget` after calling `run.Sink.Complete()`. Because `SampleRowSink.Complete()` may close a partially-filled final row (when the time window ends), `rows.Count` can reach `rowBudget` even when the sink never hit its budget during the capture. This causes `rowLimitReached` to be reported incorrectly.

### Issue Context
- `SampleRowSink.IsComplete` counts only closed rows (`_rows.Count`), not the in-progress `_open` row.
- `SampleRowSink.Complete()` closes `_open` when `!IsComplete`, which can increment `rows.Count` after the capture is already over.

### Fix Focus Areas
- src/Daqifi.Mcp/DaqifiAgent.cs[1084-1111]
- src/Daqifi.Mcp/LiveSampleCapture.cs[230-282]

### Suggested fix
- Determine whether the row budget ended the capture **before** flushing the open row. For example:
 - Compute `var rowLimitReached = run.Sink.IsComplete;` before `var rows = run.Sink.Complete();` and return that value.
 - Alternatively, extend the capture outcome to explicitly track the stop reason (window elapsed vs sink complete vs stream ended) and drive `rowLimitReached` from that, not from post-processed row count.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Daqifi.Mcp/DaqifiAgent.cs
…out of time

The final flush closes the row that was still being filled when the
window ended, and that row alone can bring the count up to the budget —
so a capture that ended on time could report rowLimitReached and send a
caller back for a continuation that does not exist. The sink now latches
whether the budget filled while the capture was still running, which is
the question being asked.
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit f233681

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green — ready for review.

2 rounds on head f233681. Round 1 (on 455c950) found one valid Medium issue: rowLimitReached was computed from the row count after the final flush, so a capture that ran out of time one row short of its budget could claim the budget stopped it. Fixed by latching that answer inside the sink when the budget actually fills, which takes the read-order trap out of the code rather than commenting on it. Round 2: Bugs (0) / Rule violations (0) / Skill insights (0), the finding struck through as resolved, 0 unresolved threads, review confirmed against this head — and re-checked after it settled.

Production code changed during the round, so the bench was re-run on the real Nyquist afterwards: still 24/24, with the 10 s capture reporting rowLimitReached=false and the 200-row one true. Full suite green on net9.0 + net10.0.

Not merging — this is for your review.

@tylerkron
tylerkron added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit c0bd936 Aug 13, 2026
1 check passed
@tylerkron
tylerkron deleted the feat/mcp-live-data-tools-498 branch August 13, 2026 22:27
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.

feat(mcp): live-data tools — an agent can configure everything and measure nothing

1 participant