Skip to content

feat(host-resources): inbound resource handlers + resolver (phase 2a)#263

Merged
mgoldsborough merged 7 commits into
mainfrom
feat/host-resources-phase2a
May 22, 2026
Merged

feat(host-resources): inbound resource handlers + resolver (phase 2a)#263
mgoldsborough merged 7 commits into
mainfrom
feat/host-resources-phase2a

Conversation

@mgoldsborough
Copy link
Copy Markdown
Contributor

@mgoldsborough mgoldsborough commented May 21, 2026

Targets main (Phase 1 / #262 merged; this branch rebased on top).

Summary

Phase 1 advertised the ai.nimblebrain/host-resources capability and gated installs on host_capabilities[k].required. Phase 2a makes the capability functional: bundle subprocesses can now call ai.nimblebrain/resources/read and ai.nimblebrain/resources/list over their MCP transport, and the platform resolves the URIs against the workspace FileStore shared with the agent's files__read.

Closes the gap that motivated the host-resources extension: bundles like synapse-research can now reach workspace files without filesystem access, without going through the agent for every byte, without bypassing the platform's workspace isolation.

What changes

New modules

  • src/host-resources/resolver.tsHostResourcesResolver interface + FileBackedHostResourcesResolver impl. Single chokepoint for scheme allowlist, workspace isolation, size cap, and host-resources debug logging. Resolves through the same FileStore as agent-side files__read.
  • src/host-resources/rate-limit.tsTokenBucketRateLimit per (workspaceId, bundleId). Defaults: 100 req/s, 1000 burst. Throws -32004 Rate limited (impl-defined server-error range) with retryAfterMs in error data.
  • src/host-resources/methods.ts — namespaced method-name constants.
  • src/files/mime.ts — shared isTextMime predicate. Single source of truth across the agent-side files__read, the bundle-side resolver, and the file extraction pipeline.

McpSource wiring

  • New optional 4th constructor parameter bundleContext: BundleMcpContext carrying { workspaceId, bundleId, hostResources, rateLimit }. In-process platform sources don't pass it.
  • registerBundleHandlers registers client.setRequestHandler for the two namespaced read/list schemas immediately after new Client() and after buildClient() (OAuth retry rebuild). Schemas extend the standard ReadResourceRequestSchema / ListResourcesRequestSchema with the method literal swapped — result shapes carry through unchanged from spec.

Runtime + threading

  • Runtime.start() constructs one resolver and one rate-limit, exposes them via getBundleMcpDeps(wsId), hands them to BundleLifecycleManager.setBundleMcpDepsFactory() and ManageBundleContext.bundleMcpDepsFactory.
  • Every production bundle-spawn path threads bundleMcp: lifecycle installNamed / installLocal / installRemote, installBundleInWorkspace (agent-facing manage_app install), configureBundle restart, Composio eager-start, post-credential-change respawn, ensureSourceRegistered, and boot reload.

Code-style infrastructure (carried over from earlier rounds)

  • CODE_STYLE.md + scripts/check-code-style.ts wired into verify:static. Rule Support version pinning for bundles in nimblebrain.json #1: no inline type imports. AST-based detection (no regex false-positives on real dynamic imports).
  • 40 pre-existing inline-type-import usages refactored across 13 files. Zero violations remain; new ones fail CI.

Key design decisions

  • Workspace identity from the session, never the URI. Cross-workspace lookups collapse to -32002 Resource not found (same response as a missing ID — no inventory enumeration).
  • Quota responses use the impl-defined server-error range, not -32603 InternalError. Rate limiting → -32004, response-too-large → -32005. Bundle SDKs can distinguish deliberate quota from server fault.
  • Schema-shape symmetry with the platform-side advertisement: bundle's host_capabilities mirrors ClientCapabilities.extensions exactly.

Tests

  • host-resources-resolver.test.ts (9) — text/blob round-trip, scheme guard, unknown ID → -32002, cross-workspace lookup collapses to -32002 (no info leak), size cap (asserts -32005), list filtering, scheme filter validation.
  • host-resources-rate-limit.test.ts (8) — burst admission, per-key isolation, refill, assertion on code === -32004 so future drift fails CI.
  • host-resources-handlers.test.ts (4) — end-to-end wire tests via InMemoryTransport.createLinkedPair. READ dispatches through resolver and back to bundle; LIST exercises the _meta.filter unwrap end-to-end for both mimeType and tags; unknown ID propagates as isError: true.

2994 unit + 482 integration + 17 smoke, all green. check:code-style clean.

Out of scope (follow-ups)

  • Operator-tunable hostResources.rateLimit config. The defaults are conservative (100/sec, 1000 burst). No operator has needed an override.
  • Cache-warm step before boot-reload's manifest gate. Today boot reload's cache-miss path is log.warn + continue; the proper fail-closed needs cache repopulation first. Separate from the wiring question (which is done).
  • Streaming / range reads / write. v1 caps whole-file reads at 10 MiB; read.range = false, write.enabled = false. The capability shape reserves them.
  • Bundle SDK packages (Phase 2b — Python only per planning).

Test plan

  • bun run verify green locally.
  • End-to-end wire tests cover both READ and LIST dispatch paths.
  • Rate-limit + ResponseTooLarge tests pin specific JSON-RPC codes so future drift fails CI.
  • Code-style check passes (bun run check:code-style finds zero inline-type-imports).

mgoldsborough added a commit that referenced this pull request May 21, 2026
…ement (QA round 5 on #263)

## Critical fixes

QA round 5 found three real bugs in the Phase 2a wiring:

1. **manage_app install bypass.** `lifecycle.installNamed/Local/Remote`
   were threaded but have zero production callers — the agent's actual
   install path is `system-tools` → `workspace-ops` → `startBundleSource`,
   which I added the param to but never threaded from the caller. Fix:
   `Runtime.getBundleMcpDeps(wsId)` getter, threaded through both
   `installBundleInWorkspaceViaCtx` (manage_app install) and
   `installConnectorBundle` (connector install).

2. **isTextMime byte-for-byte duplicated** with my own "keep in sync"
   comment between `src/host-resources/resolver.ts` and
   `src/tools/platform/files.ts`. Extracted to `src/files/mime.ts` —
   one source of truth, both paths import. The next person who adds
   `application/jsonl` updates one file, both surfaces pick it up.

3. **Boot-reload regression risk.** `workspace-runtime.ts:247` called
   `startBundleSource` without `bundleMcp`. Bundles installed with
   host-resources support would silently lose the capability across
   every platform restart. Threaded through via a `getBundleMcpDeps`
   factory in `startWorkspaceBundles` opts.

## Polish (all five QA suggestions)

- Rate-limit error code: `-32603 InternalError` → `-32004` (impl-defined
  server-error range). Rate limiting is a deliberate quota response,
  not a server fault.
- `list()` now rejects non-empty `cursor` with `-32602` instead of
  silently ignoring it — pagination isn't supported in v1, fail loudly
  so bundle SDKs can detect.
- `composeBundleMcpContext` exported from `startup.ts` and reused in
  `lifecycle.installRemote`'s direct `new McpSource(...)` site.
  Drops 8 lines of duplicated four-field composition.
- FileStore memoization in the Runtime factory closure — bounded by
  active workspace count, future-proofs against stateful FileStore.
- Comment on `TokenBucketRateLimit.buckets` non-eviction.

## Code style — extensible quality-control system

QA also pointed out that I'd used `import("path").TypeName` inline-type-
imports in my Phase 2a edits (matching a pre-existing pattern in
`runtime.ts`). The shortcut reads exactly like a runtime dynamic
`import()` and trips every reader. Rather than just fix my edits, this
PR introduces a structural fix:

- `CODE_STYLE.md` — extensible doc for project-specific rules beyond
  what Biome / tsc catch. Each rule documents anti-example, good
  example, rationale, detection, override.
- `scripts/check-code-style.ts` — AST-based check (uses TypeScript's
  `ImportTypeNode` kind) so it can't false-positive on real runtime
  dynamic imports. Wired into `verify:static` as `check:code-style`.
- AGENTS.md gains a one-line pointer; the rest of the convention text
  lives in `CODE_STYLE.md`.
- Refactored all 40 existing inline-type-import usages across 13 files
  to top-level `import type { X } from "..."`. Zero violations remain;
  the check passes. New violations fail CI from now on.

The framework is set up so future rules (no-magic-numbers, etc.) land
as one new function in `check-code-style.ts` + one section in
`CODE_STYLE.md`, with their cleanup PR.

## Verify

3041 unit + 482 integration + 17 smoke green. `check:code-style` runs
clean.
Bundle subprocesses can now call `ai.nimblebrain/resources/read` and
`ai.nimblebrain/resources/list` over their MCP transport. Phase 1
advertised the capability; Phase 2a wires the inbound handlers and the
resolver behind them. The capability is now functional end-to-end.

## New modules

- `src/host-resources/resolver.ts` — `HostResourcesResolver` interface
  + `FileBackedHostResourcesResolver` impl. Single chokepoint for
  scheme allowlist, workspace isolation, size cap, and audit logging.
  Resolves through the same workspace `FileStore` as agent-side
  `files__read` — one security surface, not two.
- `src/host-resources/rate-limit.ts` — `TokenBucketRateLimit` per
  `(workspaceId, bundleId)`. Defaults: 100 req/sec, 1000 burst.
  Throws `-32603 Rate limited` with `retryAfterMs` in error data.
- `src/host-resources/methods.ts` — the two method-name constants.

## McpSource wiring

- `BundleMcpContext` (`workspaceId`, `bundleId`, `hostResources`,
  `rateLimit`) threads through optional 4th constructor arg.
- `registerBundleHandlers` registers `setRequestHandler` for the
  namespaced read/list schemas, called immediately after `new Client()`
  (and again after the OAuth-retry `buildClient()`) so handlers are
  ready before connect. URI-scheme schemas extend the standard
  `ReadResourceRequestSchema` / `ListResourcesRequestSchema` with the
  method literal swapped, so result shapes carry through unchanged
  from the spec — Layer 3 migration is `s/ai.nimblebrain\///`.
- In-process platform sources pass no context (they don't need the
  surface; they're the platform talking to itself).

## Runtime + lifecycle threading

- `Runtime.start()` constructs one resolver (closure captures the
  workspace-scoped file-store factory) and one rate-limit, passes the
  pair to `BundleLifecycleManager.setBundleMcpDepsFactory(wsId =>
  ...)`.
- Lifecycle's `installNamed` / `installLocal` / `installRemote` resolve
  per-workspace deps from the factory and pass via
  `startBundleSource` opts (or directly for `installRemote`'s
  one-off `new McpSource` site).
- `workspace-ops.installBundleInWorkspace` accepts `bundleMcp` in opts
  for the agent-facing `manage_app install` path; system-tools calls
  pass it through.
- `system-tools.configureBundle`, connector eager-start, and boot
  reload are out of scope for Phase 2a and tracked as follow-ups.

## Tests

29 new unit tests across three files:

- `host-resources-resolver.test.ts` (9) — text + blob round-trip,
  scheme guard rejects `entities://`, unknown file ID returns -32002,
  cross-workspace lookup collapses to -32002 (no info leak),
  `maxReadSize` enforcement, list filtering by mime, scheme filter
  validation.
- `host-resources-rate-limit.test.ts` (8) — burst admission,
  per-`(wsId, bundleId)` isolation, refill at `ratePerSec`, ceiling
  cap, `McpError` shape with `retryAfterMs`.
- `host-resources-handlers.test.ts` (2) — end-to-end wire test using
  `InMemoryTransport.createLinkedPair`. A fake bundle server's tool
  calls back through `server.request("ai.nimblebrain/resources/read",
  ...)`, the platform-side `setRequestHandler` dispatches to the
  resolver, the file content makes it back to the bundle. Second
  variant: unknown file ID propagates through as an `isError: true`
  tool result. Closes the QA round-4 "no integration test" item
  without spawning a real subprocess.

3041 unit + 482 integration + 17 smoke green.
…ement (QA round 5 on #263)

QA round 5 found three real bugs in the Phase 2a wiring:

1. **manage_app install bypass.** `lifecycle.installNamed/Local/Remote`
   were threaded but have zero production callers — the agent's actual
   install path is `system-tools` → `workspace-ops` → `startBundleSource`,
   which I added the param to but never threaded from the caller. Fix:
   `Runtime.getBundleMcpDeps(wsId)` getter, threaded through both
   `installBundleInWorkspaceViaCtx` (manage_app install) and
   `installConnectorBundle` (connector install).

2. **isTextMime byte-for-byte duplicated** with my own "keep in sync"
   comment between `src/host-resources/resolver.ts` and
   `src/tools/platform/files.ts`. Extracted to `src/files/mime.ts` —
   one source of truth, both paths import. The next person who adds
   `application/jsonl` updates one file, both surfaces pick it up.

3. **Boot-reload regression risk.** `workspace-runtime.ts:247` called
   `startBundleSource` without `bundleMcp`. Bundles installed with
   host-resources support would silently lose the capability across
   every platform restart. Threaded through via a `getBundleMcpDeps`
   factory in `startWorkspaceBundles` opts.

- Rate-limit error code: `-32603 InternalError` → `-32004` (impl-defined
  server-error range). Rate limiting is a deliberate quota response,
  not a server fault.
- `list()` now rejects non-empty `cursor` with `-32602` instead of
  silently ignoring it — pagination isn't supported in v1, fail loudly
  so bundle SDKs can detect.
- `composeBundleMcpContext` exported from `startup.ts` and reused in
  `lifecycle.installRemote`'s direct `new McpSource(...)` site.
  Drops 8 lines of duplicated four-field composition.
- FileStore memoization in the Runtime factory closure — bounded by
  active workspace count, future-proofs against stateful FileStore.
- Comment on `TokenBucketRateLimit.buckets` non-eviction.

QA also pointed out that I'd used `import("path").TypeName` inline-type-
imports in my Phase 2a edits (matching a pre-existing pattern in
`runtime.ts`). The shortcut reads exactly like a runtime dynamic
`import()` and trips every reader. Rather than just fix my edits, this
PR introduces a structural fix:

- `CODE_STYLE.md` — extensible doc for project-specific rules beyond
  what Biome / tsc catch. Each rule documents anti-example, good
  example, rationale, detection, override.
- `scripts/check-code-style.ts` — AST-based check (uses TypeScript's
  `ImportTypeNode` kind) so it can't false-positive on real runtime
  dynamic imports. Wired into `verify:static` as `check:code-style`.
- AGENTS.md gains a one-line pointer; the rest of the convention text
  lives in `CODE_STYLE.md`.
- Refactored all 40 existing inline-type-import usages across 13 files
  to top-level `import type { X } from "..."`. Zero violations remain;
  the check passes. New violations fail CI from now on.

The framework is set up so future rules (no-magic-numbers, etc.) land
as one new function in `check-code-style.ts` + one section in
`CODE_STYLE.md`, with their cleanup PR.

3041 unit + 482 integration + 17 smoke green. `check:code-style` runs
clean.
@mgoldsborough mgoldsborough force-pushed the feat/host-resources-phase2a branch from 76bce05 to 0bed1b6 Compare May 22, 2026 00:00
@mgoldsborough mgoldsborough changed the base branch from feat/host-resources-phase1 to main May 22, 2026 00:00
…drift (QA round 6 on #263)

## Critical fixes

QA round 6 found two real bugs:

1. **Rate-limit error code drift in docs.** Round 5 changed the
   implementation from `-32603 InternalError` to `-32004` (impl-defined
   server-error range — semantically correct for rate limiting), but
   four doc surfaces still said `-32603`. A bundle SDK author matching
   on `error.code === -32603` to detect rate limiting would silently
   fail to back off. Updated `rate-limit.ts` docstring, `mcp-source.ts`
   handler comment, the test file header, and the test now asserts on
   `caught?.code === -32004` so future drift fails CI rather than
   silently shifting the contract.

2. **`ensureSourceRegistered` bypass.** Composio OAuth reconnect path
   (`composio-auth.ts` → `lifecycle.ts:1454`) re-spawned the bundle via
   `startBundleSource` without `bundleMcp`, silently dropping
   host-resources handlers on every reconnect. One-line fix: thread
   `this.resolveBundleMcpDeps(wsId)` through opts.

## Polish

- **Third `isTextMime` copy in `src/files/extract.ts`** — round 5 only
  caught two of three. The third had stricter semantics (no `text/*`
  wildcard, no parameter stripping), but param stripping already
  happens at the call site (`mimeType.split(";", 1)[0]` upstream). Swap
  to the shared `mime.ts`; minor behavior change — `text/sql`,
  `text/css`, and other un-curated `text/*` mimes are now treated as
  extractable. Strict improvement: those bytes ARE text. If extraction
  ever fails the function still returns null via the existing catch.
- **List handler filter-fallback comment was lying.** Code only reads
  `params._meta?.filter` but comment promised a top-level fallback for
  forward-compat. Trimmed the comment to match reality plus a "if the
  spec ever adds `filter` to top-level, accept it here" note.

3041 unit (assert-on-code addition) + 482 integration + 17 smoke green.
… code + doc honesty (QA round 7 on #263)

## Critical fixes

QA round 7 found two real bugs:

1. **3 respawn paths drop host-resources handlers.** Same pattern as
   `ensureSourceRegistered` (round 6): credential-change respawn paths
   re-spawn the bundle via `startBundleSource` without `bundleMcp`,
   silently dropping handlers. Threaded through all three:

   - `system-tools.configureBundle` (configure restart) — threaded via
     `ManageBundleContext.bundleMcpDepsFactory`
   - `connector-tools.ts:1289` (Composio install eager-start) —
     `ctx.runtime.getBundleMcpDeps(wsId)`
   - `connector-tools.ts:2391` (`respawnBundleAfterCredentialChange`) —
     same

   The PR-body argument ("no current consumer depends on host-resources")
   held for synapse-research today (env-only), but the moment a Phase-2b
   bundle uses both user_config and host-resources, set_user_config on
   it would silently disable host-resources until the next platform
   restart. Closing now while threading is fresh.

2. **False config-claim in rate-limit docstring.** Promised operators
   `nimblebrain.json`'s `hostResources.rateLimit` block — but no schema
   exists and `Runtime.start()` calls `new TokenBucketRateLimit()` with
   no args. Operators reading the comment would set a config block that
   does nothing. Stripped the false claim; the defaults remain
   hard-coded and operator-tunable rate-limit is a tracked follow-up.

## Polish

- **`ResponseTooLarge` error code inconsistency.** Rate limiting moved
  to `-32004` in round 6 with explicit rationale ("a deliberate quota
  response, not a server fault"). Size-cap was still `-32603
  InternalError` — same kind of deliberate quota response. Moved to
  `-32005` (impl-defined server-error range). Test pinned via
  `expect(caught?.code).toBe(-32005)` so future drift fails CI.
- **CHANGELOG note on `files__read` text-extraction broadening.** The
  shared `isTextMime` from `mime.ts` (round 5 consolidation) treats any
  `text/*` mime as UTF-8-decodable; the old `extract.ts` predicate did
  exact-match on a 9-mime allowlist. Practical effect documented:
  `text/sql`, `text/css`, etc. now extract from `files__read` instead
  of returning null. The decode falls back safely on garbage bytes.

## Wire test deferral

QA flagged adding LIST + rate-limit cases to the wire test (`InMemoryTransport`-driven dispatch via `host-resources-handlers.test.ts`). Skipping — standalone unit tests cover both behaviors and the dispatch shape is identical to READ. A 20-line addition is the kind of belt-and-suspenders that naturally lands with Phase 2c's synapse-research adoption.

## Verify

2991 unit + 482 integration + 17 smoke green. `check:code-style` clean.
…e test (QA round 8 on #263)

## Critical fix

Stale "non-wired follow-ups" claim in CHANGELOG. Round 7 wired all
three respawn paths (`configureBundle`, Composio eager-start,
`respawnBundleAfterCredentialChange`) plus boot reload from round 5,
but the CHANGELOG bullet still listed them as follow-ups. Same code-
drift pattern as the rate-limit error code drift in round 6. Rewrote
to reflect reality: every production bundle-spawn path threads
`bundleMcp`.

## Polish

- **`resolver.list` defensive validation of `filter.tags`.** A buggy
  bundle sending `{ tags: "single-tag" }` (string) instead of
  `{ tags: ["single-tag"] }` (string array) would throw TypeError on
  `.every`. Treat non-array as no filter — `Array.isArray` guard at
  the unwrap. Low blast radius (one bundle, one rate-limited request),
  but the fix is one line.
- **LIST wire test added** (`host-resources-handlers.test.ts`). The
  read wire test proves dispatch works through `setRequestHandler`,
  but the list handler has additional logic — it unwraps
  `params._meta.filter` from a non-standard location (spec
  `ListResourcesRequest` doesn't carry `filter` at top level). The
  resolver-level list tests prove the resolver works, but not that the
  bundle's `_meta.filter` reaches the resolver intact. A future
  refactor of the param parse could silently swap filtered for
  unfiltered results — the new test catches that.

## Verify

2992 unit (+1 new wire test) + 482 integration + 17 smoke green.
`check:code-style` clean.

We are converging — round 8's "critical" is a doc-drift, not a bug.
…tags wire test (QA round 9 on #263)

## Critical fix

`HostResourcesReadCapability.maxSize` JSDoc in `capability.ts:32`
promised `-32603 ResponseTooLarge`, but round 7 changed the
implementation to `-32005`. Same drift pattern flagged in rounds 6 and
8 — Phase 1's forward-looking promise turned into a lie the moment
Phase 2's wire code shipped. Bundle SDK authors reading the type
declaration to understand the contract would write retry logic against
a code the wire never returns. One-line fix.

## Polish

- **Log on read failures before the `-32002` collapse.** The
  cross-tenant-info-leak protection is correct — every store error
  collapses to "Resource not found" so a workspace can't probe another
  workspace's inventory. But that also masks disk I/O / permission /
  corruption errors from operators. Added a `log.warn` before the
  throw that surfaces the underlying error message with bundle +
  workspace context. Wire response unchanged.
- **Wire test for `_meta.filter.tags`.** The round-8 mimeType wire
  test covered the generic `_meta.filter` unwrap, but the handler's
  cast in `mcp-source.ts` also includes `tags?: string[]`. A future
  refactor that narrows the cast (drops `tags` for "simpler typing")
  would pass the mimeType test silently and break tags-based
  filtering. New test seeds two tagged files, calls list with
  `_meta.filter.tags: ["draft"]`, asserts only the tagged one
  comes back.

## Deferred (with concurrence)

- **Bucket map eviction on the rate limiter.** QA acknowledged
  "acceptable today" — bounded by installed-bundles × active-
  workspaces, restart is the pressure-release valve. Lives naturally
  with the operator-tunable `hostResources.rateLimit` follow-up.

## Verify

2993 unit (+1 new tags wire test) + 482 integration + 17 smoke green.
`check:code-style` clean.

Round 9's "critical" is doc drift on a JSDoc line. Convergence
continues.
@mgoldsborough mgoldsborough added the qa-reviewed QA review completed with no critical issues label May 22, 2026
… of returning all files (QA round 10 on #263)

Round 8's defensive Array.isArray guard prevented a TypeError when a
buggy bundle sent `tags: "draft"` (string) instead of `tags: ["draft"]`
(string array). But the fallback returned ALL files (no filter applied)
— contradicting the comment that promised an empty-result debug
signal. A bundle author would see their filter "not working" and might
or might not connect the dots.

Aligning with the scheme-filter branch (which throws `-32602
InvalidParams` on malformed input): throw the same code with
`receivedType` in error data. Same shape, same actionable diagnostic.
The bundle author gets an immediate explicit error instead of
silently-wrong results.

Considered alternatives:
- Return empty list. Matches the original comment claim but is still
  misleading — empty vs invalid input look the same on the wire.
- Drop the comment to match silent fallback. Rejected — `-32602` is
  strictly better than "you have to figure out why all files came
  back."

New resolver test pins the behavior. Wire test for the same path is
covered by round-9's `_meta.filter.tags` test.

2994 unit (+1 new validation test) + 482 integration + 17 smoke green.
@mgoldsborough mgoldsborough merged commit 4d5d919 into main May 22, 2026
4 checks passed
@mgoldsborough mgoldsborough deleted the feat/host-resources-phase2a branch May 22, 2026 01:55
mgoldsborough added a commit that referenced this pull request May 22, 2026
…ase 2b) (#268)

* feat(bundle-sdk-py): Python SDK for ai.nimblebrain/host-resources (phase 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.

* fix(bundle-sdk-py): clean up misleading config + widen CI lint (QA round 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/.

* fix(bundle-sdk-py): per-method capability gates + mcp upper bound (QA 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/.

---------

Co-authored-by: Mathew Goldsborough <[email protected]>
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