Skip to content

feat(bundle-sdk-py): Python SDK for ai.nimblebrain/host-resources (phase 2b)#268

Merged
mgoldsborough merged 3 commits into
mainfrom
feat/bundle-sdk-py
May 22, 2026
Merged

feat(bundle-sdk-py): Python SDK for ai.nimblebrain/host-resources (phase 2b)#268
mgoldsborough merged 3 commits into
mainfrom
feat/bundle-sdk-py

Conversation

@mgoldsborough
Copy link
Copy Markdown
Contributor

Phase 2b of the host-resources roadmap. Phase 1 (#262) advertised the capability and gated installs; Phase 2a (#263) wired the platform-side handlers and resolver. This PR ships the bundle-side wrapper: bundle authors write `await host(ctx).read("files://fl_abc")` instead of hand-rolling the JSON-RPC call.

Unblocks Phase 2c — the synapse-research adoption that closes the original production bug.

New package

`packages/bundle-sdk-py/` — first top-level `packages/` in this monorepo. PyPI name: `nimblebrain-bundle-sdk`. Python 3.11+. Pinned to `fastmcp>=3.0` and `mcp>=1.27`, matching what synapse-research and other production bundles use.

API surface

```python
from fastmcp import Context
from nimblebrain_bundle_sdk import host, HostCapabilityMissing

@mcp.tool
async def my_tool(seed_uri: str | None, seed_data: str | None, ctx: Context):
h = host(ctx)
if seed_uri and h.available:
result = await h.read(seed_uri)
return result.contents[0].text
elif seed_data:
return seed_data
elif seed_uri:
# Level-C fallback: capability missing, teach the agent to retry inline.
raise HostCapabilityMissing("ai.nimblebrain/host-resources")
```

  • `host(ctx)` → `HostResources` handle
  • `HostResources.available` / `.supports_scheme(scheme)` — capability probes
  • `HostResources.read(uri)` → `ReadResourceResult`
  • `HostResources.list(*, scheme=, mime_type=, tags=)` → `ListResourcesResult`
  • `HostCapabilityMissing` exception
  • Named error-code constants (`RATE_LIMITED = -32004`, `RESPONSE_TOO_LARGE = -32005`, `RESOURCE_NOT_FOUND = -32002`, `INVALID_PARAMS = -32602`)

Implementation notes

  • Capability detection reads `ClientCapabilities.extensions["ai.nimblebrain/host-resources"]` (spec-blessed location), falls back to `experimental` for older platforms. The Python `mcp` SDK (1.27.0) doesn't have a typed `extensions` field; it lives in `model_extra` via Pydantic's `extra="allow"`. SDK handles the accessor path.
  • Custom-method requests can't be passed to `ServerSession.send_request` directly — its typevar is constrained to the spec's closed `ServerRequest` union. At runtime `send_request` only calls `.model_dump()`, so any Pydantic model with the right wire shape works. We define typed request models and `cast` to `ServerRequest` at the call site (static-type accommodation, not a runtime requirement).
  • `_meta` field wire shape can't be a Python attribute name (Pydantic treats leading-underscore as private). Use `Field(alias="_meta")` with `populate_by_name=True`.

Tests

14 tests, all passing. Covers capability detection (extensions + experimental + malformed shapes), scheme probes, read/list dispatch shapes (params, `_meta.filter` unwrap), and `HostCapabilityMissing` raising locally without a wire call.

Release tooling

`.github/workflows/release-bundle-sdk-py.yml` triggers on `bundle-sdk-py/v*` tags (distinct from the platform's `v*`). Verifies tag matches `pyproject.toml` version, builds via `uv build`, publishes to PyPI via Trusted Publishing (OIDC — no API token), creates a GitHub Release.

One-time PyPI setup is required before the first release (operator action, documented in the workflow header):

  1. Reserve the `nimblebrain-bundle-sdk` name on PyPI (manual first upload).
  2. Add a Trusted Publisher pointing at this workflow.
  3. Configure the `pypi` environment in the repo's GitHub settings.

After that, every `bundle-sdk-py/v0.x.y` tag publishes automatically.

Test plan

  • `uv run pytest` green (14 tests).
  • `uv run ruff check` + `format --check` clean.
  • `uv build` produces `nimblebrain_bundle_sdk-0.1.0` sdist + wheel.
  • `bun run verify` green — the new `packages/` directory doesn't disrupt platform CI.
  • Reviewer: confirm the `bundle-sdk-py/v*` tag convention is acceptable. Platform releases continue on `v*` — no glob collision.
  • Reviewer: PyPI Trusted Publisher / environment config is out of scope for this PR; first release fires the workflow which will fail until the operator setup lands. That's the trigger to do the one-time setup.

What's next

  • Phase 2c — synapse-research adoption. Lives in `synapse-apps/synapse-research` (separate submodule). Signature change: accept both `seed_uri` and `seed_data`, use the SDK's Level-A/C fallback pattern. Closes the original bundle-can't-read-workspace-files production bug. Should be ~2 days.

…ase 2b)

Bundle-side wrapper for the host-resources extension shipped in PR #263.
Bundle authors write `await host(ctx).read("files://fl_abc")` instead of
hand-rolling the JSON-RPC call shape.

## New package: `nimblebrain-bundle-sdk`

Lives at `packages/bundle-sdk-py/`. First top-level `packages/` directory
in this monorepo. Python 3.11+, fastmcp >=3.0, mcp >=1.27. Standard
hatchling/ruff/ty/pytest layout matching synapse-research conventions.

## API

- `host(ctx)` — factory returning a `HostResources` handle wrapping
  the bundle's `Context`.
- `HostResources.available` — capability probe. Reads
  `ClientCapabilities.extensions["ai.nimblebrain/host-resources"]`
  (the spec-blessed location), falls back to `experimental` for older
  platforms. Returns False on any malformed shape so a buggy host
  can't crash the bundle's probe.
- `HostResources.supports_scheme(scheme)` — per-scheme allowlist check.
- `HostResources.read(uri)` — wraps `ai.nimblebrain/resources/read`,
  returns the MCP-standard `ReadResourceResult`.
- `HostResources.list(*, scheme=, mime_type=, tags=)` — wraps
  `ai.nimblebrain/resources/list` with the platform's `_meta.filter`
  unwrap convention.
- `HostCapabilityMissing` exception — supports the Level-C fallback
  pattern (catch + return a structured tool error teaching the agent
  to retry with inline content).
- Error-code constants (`RATE_LIMITED = -32004`, `RESPONSE_TOO_LARGE
  = -32005`, etc.) so bundles match on names, not magic numbers.

## Implementation notes

- `ServerSession.send_request` is typed against `ServerRequest`, a
  closed Pydantic `RootModel` union of the spec's known methods. Our
  custom-method requests aren't in that union. At runtime,
  `send_request` only calls `request.model_dump()` — any Pydantic
  model with the right shape works. We pass typed BaseModel subclasses
  and `cast` them to `ServerRequest` at the call site (static-type
  accommodation only).
- `_meta` field name has a leading underscore (MCP spec convention).
  Pydantic forbids leading-underscore attribute names; we use
  `Field(alias="_meta")` with `populate_by_name=True` so the wire
  shape stays correct while the Python attribute is `meta`.

## Tests

14 tests, all passing. Covers:

- Capability detection: extensions, experimental fallback, read
  disabled, malformed shape.
- Scheme allowlist probes.
- `read` dispatch shape (method name + params).
- `list` dispatch shape: no filter → empty params; mime_type filter
  → `_meta.filter.mimeType`; tags filter → `_meta.filter.tags`;
  combined filters.
- `HostCapabilityMissing` raised locally without wire call.

## Release tooling

`.github/workflows/release-bundle-sdk-py.yml` triggers on
`bundle-sdk-py/v*` tags (distinct from the platform's `v*`). Verifies
the tag matches `pyproject.toml`'s version, builds wheel + sdist via
`uv build`, publishes to PyPI via Trusted Publishing (OIDC — no
long-lived API token), creates a GitHub Release.

First-time PyPI setup (one-time per package, by an operator) is
documented in the workflow header.

## Platform verify

`bun run verify` green — the new `packages/` directory doesn't
interact with the platform build path. Static checks, unit, integration,
smoke all clean.
…und 11 on #268)

Three real issues from QA round 11:

## Polish

- **`_HostResourcesListFilter`'s `populate_by_name = True` was dead
  config + lying docstring.** The class declared the Pydantic config
  and the comment promised snake_case ↔ camelCase aliasing — but no
  `Field(alias=...)` was ever wired. Serialization actually went
  through a hand-rolled `to_wire()` dict-build. Removed the dead
  config and rewrote the docstring to describe what the code actually
  does: explicit dict-build is simpler than alias plumbing for an
  internal model whose only caller is `HostResources.list`.

- **`supports_scheme` defensive branch was untested.** The
  `isinstance(schemes, list)` guard in `host.py:146` protects the
  bundle from a host that advertises `schemes` as a non-list. New
  test mirrors `test_available_false_on_malformed_shape` — pins the
  guard so a future "simplification" fails CI.

- **CI lint scope didn't include `tests/`.** The release workflow
  ran `ruff check src/` only, which let an I001 import-order
  violation in `tests/test_host.py` ship to QA. Widened to
  `src/ tests/` for both lint and format-check. Release-time lint
  failures block a publish, so catching scope drift on PR is
  strictly better.

## Verify

15 unit (+1 new defensive test) + ruff clean on both src/ and tests/.
… round 12 on #268)

## Per-method capability gates (suggestion 1)

`HostResources.list()` previously gated on `available`, which only
reads `cap["read"]["enabled"]`. A future host that advertises
`list.enabled=true` with `read.enabled=false` would have caused `list()`
to falsely raise HostCapabilityMissing — the SDK was silently baking
in v1's lockstep assumption. Fix:

- Add `_method_enabled(method)` helper that gates per-method.
- `read()` gates on `_method_enabled("read")`.
- `list()` gates on `_method_enabled("list")`.
- `available` (the common-case probe for `read`) stays as-is but
  docstring now explicitly says it's the read-only probe.
- New `list_available` property mirrors `available` for the list path.
  v1 hosts move both flags in lockstep so it's a no-op today, but the
  shape allows independent flags and the SDK should follow the shape,
  not the v1 convention.

## mcp upper bound (suggestion 3)

The `cast(ServerRequest, request)` shim in host.py relies on
`BaseSession.send_request` calling only `.model_dump()` on the input
— behavior verified against mcp 1.27.x but not contractually
guaranteed. A 2.0 release could plausibly tighten input validation and
break us at runtime (not import). Pinned `mcp>=1.27.0,<2.0.0` and
documented the contract dependency inline at the cast site + on the
dep itself.

## Suggestion 2 (docstring drift)

Naturally resolved by suggestion 1's per-method gates: each method's
docstring now precisely names the flag it checks (`read.enabled` vs
`list.enabled`).

## Tests

21 passing (was 15, +6 new):
- list_available reflects list.enabled independently of read.enabled
- available reflects read.enabled independently of list.enabled
- list_available defaults False when no caps
- list_available False on malformed list block
- list() raises when only read.enabled
- read() raises when only list.enabled

Lint + format clean on src/ and tests/.
@mgoldsborough mgoldsborough added the qa-reviewed QA review completed with no critical issues label May 22, 2026
@mgoldsborough mgoldsborough merged commit 852cbdd into main May 22, 2026
4 checks passed
@mgoldsborough mgoldsborough deleted the feat/bundle-sdk-py branch May 22, 2026 19:37
mgoldsborough added a commit to NimbleBrainInc/synapse-research that referenced this pull request May 22, 2026
PR NimbleBrainInc/nimblebrain#268 merged as 852cbdd. The previous git
ref pointed at the SDK PR branch (89cd28f) which was deleted on
merge — the ref still resolves via github.com's commit-keyed fetch,
but tracking a deleted branch's tip is confusing. Bump to the
squash-merge commit on main.

Still git-sourced, not PyPI; the actual `bundle-sdk-py/v0.1.0` PyPI
publish requires the one-time Trusted Publisher setup. Once that
lands, drop the `[tool.uv.sources]` block entirely and let the
version pin in `[project.dependencies]` resolve through PyPI.
mgoldsborough added a commit to NimbleBrainInc/synapse-research that referenced this pull request May 23, 2026
* feat: accept seed_uri via host-resources SDK (phase 2c)

Add an optional `seed_uri` parameter to `start_research`. When set, the
server resolves the URI via the `ai.nimblebrain/host-resources`
extension (using `nimblebrain-bundle-sdk`'s `host(ctx).read()`) and
prepends the file content to the research prompt. Closes the original
production bug — synapse-research can now anchor research on a
workspace file (brokers.csv, transcript, doc) the agent points it at,
instead of either receiving content inlined in the query (large + ugly)
or doing web research from scratch (the bug we hit).

## Behaviour

- `seed_uri="files://fl_abc"`: server reads the file, prepends content
  under a `## Workspace seed` header in the prompt, records
  `seed_content_chars` on the `research_run` entity.
- Seeds above 400 KB are truncated with a visible marker so the model
  and the human reader both see the cut happened.
- Binary resources (no `text` field) are refused with a clear error
  telling the agent to extract text upstream.
- Hosts that don't advertise the host-resources extension surface a
  `HostCapabilityMissing` error naming the capability — the agent
  knows to retry with content inline via its own file-reading tool
  (the Level-C fallback pattern from the host-resources design).

## SDK source

Pins `nimblebrain-bundle-sdk>=0.1.0`. Until the SDK lands on PyPI, the
`[tool.uv.sources]` block sources from the local path at
`../../products/nimblebrain/code/packages/bundle-sdk-py/` (relative to
this pyproject.toml — requires the `hq` meta-repo cloned alongside
synapse-research, the standard NimbleBrain dev layout). Drop that
block once the package is on PyPI.

**Ordering:** this PR depends on `NimbleBrainInc/nimblebrain#268`
(the SDK package) merging first. Otherwise the local path doesn't
exist and `uv sync` fails.

## Tests

48 passing (43 existing + 5 new). The new file covers:
- happy path: seed content reaches the worker's gpt-researcher prompt
- entity records `seed_content_chars` correctly
- Level-C: `HostCapabilityMissing` surfaces with the capability name
- binary refusal: clear error directing the agent to extract upstream
- backward-compat: omitting `seed_uri` is a no-op
- SDK importability smoke

Tests mock at the `host()` factory boundary because the Python
`mcp.client.ClientSession` rejects custom-method server→client
requests (its `ServerRequest` union is closed). Wire-shape validation
lives in the SDK's own unit tests and the platform's TS-side handler
tests, both of which already exist.

* ci: source bundle-sdk-py from git instead of local path

synapse-research CI clones only its own repo — the local-path
`tool.uv.sources` (`../../products/nimblebrain/code/packages/bundle-sdk-py`)
resolves on developer machines with the `hq` meta-repo cloned
alongside, but breaks in CI where the parent path doesn't exist.

Switch to a git source pinned to a specific commit on the SDK PR
branch (`nimblebrain@89cd28f`). Reproducible builds, works in CI
without a meta-repo clone, and bumps explicitly when the SDK ships
a new pre-1.0 revision.

Drop this block once `nimblebrain-bundle-sdk` is on PyPI; the
version pin in `[project.dependencies]` resolves through PyPI from
then on. Until then, contributors editing the SDK locally should
`uv pip install -e <path>` against their checkout.

* feat: add seed_data parameter as universal inline fallback

The previous version added `seed_uri` (host-resources-extension read)
but left a real gap: hosts without the extension had nowhere to put
seed content. The error message even told the agent to "pass content
inline via `seed_data`" — except `seed_data` didn't exist as a
parameter. Fixing that.

## Changes

`start_research` now takes both `seed_uri` and `seed_data`,
mutually exclusive:

- `seed_uri`: host reads via `ai.nimblebrain/host-resources`.
  Preferred when available — the agent doesn't pay context budget
  on the file bytes.
- `seed_data`: raw text passed inline by the agent. Universal
  fallback that works on every host. Required for hosts without
  the extension.

When `seed_uri` is set on a host that doesn't advertise the
extension, the tool now returns a `ValueError` whose message names
both the missing capability AND the specific retry shape
(`seed_data=<file contents>`). The previous error pointed at a
non-existent parameter — a Level-C signal that wasn't actionable.

Passing both `seed_uri` and `seed_data` is rejected as ambiguous
rather than picking one; the agent probably confused the two paths.

## Tests

8 passing in tests/test_seed_uri.py (+3 new):
- `seed_data` inline reaches the worker prompt
- `seed_uri` + `seed_data` together → mutex error
- Capability-missing error tells the agent to retry with `seed_data`
  specifically (not just "pass content inline")

All 51 tests (43 existing + 8 in test_seed_uri.py) pass.

* style: ruff format

* fix(manifest): briefing.priority "normal" → "medium"

The platform's host-manifest schema constrains `briefing.priority`
to `["high", "medium", "low"]` (host-manifest.schema.json:153). The
existing `"normal"` value was never valid against this enum — it
worked previously because the platform didn't enforce the schema
strictly. Recent installs fail with:

  Bundle "@nimblebraininc/synapse-research" has an invalid
  _meta["ai.nimblebrain/host"] block:
  ai.nimblebrain/host/briefing/priority: must be equal to one of the
  allowed values. Refusing to install.

Switching to `"medium"` — it's the middle bucket, matching the prior
intent (default-ish priority for this app's briefing facets).

Unrelated to the Phase 2c work in this PR; folded in because it
blocks local testing of any synapse-research install against current
platform main.

* chore: bump bundle-sdk-py source to post-merge SHA on platform main

PR NimbleBrainInc/nimblebrain#268 merged as 852cbdd. The previous git
ref pointed at the SDK PR branch (89cd28f) which was deleted on
merge — the ref still resolves via github.com's commit-keyed fetch,
but tracking a deleted branch's tip is confusing. Bump to the
squash-merge commit on main.

Still git-sourced, not PyPI; the actual `bundle-sdk-py/v0.1.0` PyPI
publish requires the one-time Trusted Publisher setup. Once that
lands, drop the `[tool.uv.sources]` block entirely and let the
version pin in `[project.dependencies]` resolve through PyPI.

* chore: switch nimblebrain-bundle-sdk source from git to PyPI

nimblebrain-bundle-sdk v0.1.0 is now on PyPI:
https://pypi.org/project/nimblebrain-bundle-sdk/0.1.0/

Drop the `[tool.uv.sources]` git-source override. The `>=0.1.0` pin in
`[project.dependencies]` now resolves through PyPI, which means fresh
clones (devs, CI runners) install the SDK without needing the `hq`
meta-repo cloned alongside or a specific git commit fetched.

uv.lock updated to reflect the registry source. 51 tests still green.

* fix(seed): scheme probe + empty-text guard + truncation test (QA round 13 on #8)

Three substantive fixes from QA review:

## Truncation test (Critical #4)

`_SEED_MAX_CHARS = 400_000` was implemented without a test exercising
the slicing path or the marker shape. Added
`test_seed_truncation_emits_marker` that constructs a seed >cap with
sentinel head/tail strings, asserts the head survives the cut, the
tail is dropped, and the marker carries both the cap and the actual
length in its formatted-number form. Future refactors that touch the
cap or the marker text now fail loudly.

## Empty-text resource distinct from binary (Suggestion #1)

`if not text:` matched both `None` (binary) and `""` (empty text).
A legitimately empty workspace file was reported as binary with a
misleading "extract text upstream" recovery hint. Tightened to
`if text is None:` for the binary branch and added a dedicated
empty-text branch whose error tells the agent the file is actually
empty (verify contents, or omit `seed_uri`). New test pins this
distinction.

## Scheme probe before read (Suggestion #3)

`_resolve_seed_uri` now calls `h.supports_scheme("files")` after the
availability check. The platform would otherwise return
`-32602 Invalid params` for an unsupported scheme, which the agent
sees as a generic wire error. Routing it through the same Level-C
retry hint (pass `seed_data` inline) gives the agent an actionable
recovery path. Not load-bearing today (the platform always supports
`files://`), but cheap insurance and exercises the SDK's
`supports_scheme()` surface.

## Tests

54 passing (43 existing + 11 in test_seed_uri.py, +3 new):
- test_seed_truncation_emits_marker
- test_seed_uri_empty_text_distinct_from_binary
- test_seed_uri_scheme_not_supported

All existing fixtures (`seeded_host`, `unavailable_host`,
`binary_host`) grew a `supports_scheme()` method to match the new
SDK probe. Lint + format clean on src/ and tests/.

* fix: untrack accidentally-staged .claude/worktrees submodule pointer

QA reviewer's worktree at `.claude/worktrees/feat-host-resources-sdk-adoption`
got staged as an embedded repo in the previous commit because `.claude/`
wasn't in `.gitignore`. Untrack the pointer + add the directory to
gitignore so future QA worktrees don't reintroduce it.

* fix(gitignore): restore .tasks/ + actually add .claude/ (fixes 9a99def)

Commit 9a99def corrupted .gitignore by appending `.claude/` via
`echo >>` to a file whose last line had no trailing newline. The
concatenation produced:

  .tasks/.claude/

which broke BOTH intended behaviours:
  - `.tasks/foo` was no longer ignored (`/implement` scratch leaked)
  - `.claude/worktrees/foo` was never ignored (QA worktree submodule
    pointers can still be re-staged — the bug 9a99def was meant to fix)

The only path that was newly ignored — `.tasks/.claude/` — doesn't
exist in this repo.

Split into two distinct lines with proper newline terminators.
Verified with `git check-ignore -v`:

  .gitignore:28:.tasks/   .tasks/foo
  .gitignore:31:.claude/  .claude/worktrees/foo

---------

Co-authored-by: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qa-reviewed QA review completed with no critical issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant