Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/release-bundle-sdk-py.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: Release bundle-sdk-py

# Triggered by tags matching `bundle-sdk-py/v*` (e.g. `bundle-sdk-py/v0.1.0`).
# Builds the wheel + sdist, publishes to PyPI via Trusted Publishing
# (OIDC — no long-lived API token in repo secrets), creates a GitHub
# Release. The platform release workflow (`release.yml`) is unchanged;
# tags glob `v*` don't match the prefixed pattern here, so the two
# pipelines stay independent.
#
# First-time PyPI setup (one-time per package, by an operator):
# 1. Create the `nimblebrain-bundle-sdk` project on PyPI (manual
# upload of the first sdist or via `uv publish` with a token).
# 2. On PyPI, add a Trusted Publisher pointing at:
# - repo: NimbleBrainInc/nimblebrain
# - workflow: release-bundle-sdk-py.yml
# - environment: pypi
# 3. Configure the `pypi` environment in this repo's GitHub settings
# (Actions → Environments → New) for the same name. Optional
# reviewer approval gate before publish.
# After that, every `bundle-sdk-py/v*` tag publishes automatically.

on:
push:
tags:
- 'bundle-sdk-py/v*'

permissions:
contents: write # for GitHub Release creation
id-token: write # for PyPI Trusted Publishing (OIDC)

jobs:
verify:
name: Verify SDK
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/bundle-sdk-py
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install Python
run: uv python install 3.13
- name: Install dependencies
run: uv sync --frozen --no-install-project
- name: Lint
# Include `tests/` so test-only imports/formatting drift fails
# CI rather than getting caught on a release tag — release-time
# lint failures block a publish, so scope shifts here are
# better caught on PR.
run: uv run ruff check src/ tests/
- name: Format check
run: uv run ruff format --check src/ tests/
- name: Test
run: uv run pytest tests/ -v

build-and-publish:
name: Build + publish to PyPI
runs-on: ubuntu-latest
needs: verify
environment:
name: pypi
url: https://pypi.org/p/nimblebrain-bundle-sdk
defaults:
run:
working-directory: packages/bundle-sdk-py
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Verify tag matches package version
# Catches `bundle-sdk-py/v0.1.0` tagged against a pyproject.toml
# still on `0.0.x`. Tag is the source of truth for version
# numbers, but the pyproject.toml has to match because that's
# what gets baked into the wheel.
run: |
TAG="${GITHUB_REF#refs/tags/bundle-sdk-py/v}"
PKG_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')"
if [ "$TAG" != "$PKG_VERSION" ]; then
echo "Tag version ($TAG) does not match pyproject.toml version ($PKG_VERSION)"
exit 1
fi
echo "Version match: $TAG"
- name: Build sdist + wheel
run: uv build
- name: Publish to PyPI
# Trusted Publishing — no token needed; OIDC from id-token
# permission authenticates against the PyPI publisher config.
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/bundle-sdk-py/dist
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: bundle-sdk-py ${{ github.ref_name }}
body: |
Published to PyPI: https://pypi.org/project/nimblebrain-bundle-sdk/
See `packages/bundle-sdk-py/CHANGELOG.md` for changes.
files: |
packages/bundle-sdk-py/dist/*.whl
packages/bundle-sdk-py/dist/*.tar.gz
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

### Added

- **`nimblebrain-bundle-sdk` Python package — Phase 2b.** Bundle-side wrapper for the `ai.nimblebrain/host-resources` extension. Bundle authors write `await host(ctx).read("files://fl_abc")` instead of hand-rolling the JSON-RPC call. Capability detection from `ClientCapabilities.extensions` with a fallback to the legacy `experimental` slot. Lives in `packages/bundle-sdk-py/`, released independently via `bundle-sdk-py/v*` tags (separate from the platform's `v*` tags). Initial version `0.1.0`. Published to PyPI as `nimblebrain-bundle-sdk`.
- **Host-resources extension — Phase 2a (handlers + resolver).** 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`. One chokepoint, one security surface — same scheme allowlist, same workspace isolation. Bundle requests carry only the URI; workspace identity comes from the bundle's session, never the URI, so a workspace can't enumerate another's files. Per-`(workspaceId, bundleId)` token-bucket rate limit (100/sec, 1000 burst; returns `-32004 Rate limited` on exhaustion) prevents a runaway bundle from DoSing the FileStore. Whole-file reads cap at 10 MiB and return `-32005 ResponseTooLarge` above; range reads and write are reserved for a later phase. Threaded through every production bundle-spawn path: lifecycle `installNamed` / `installLocal` / `installRemote`, `installBundleInWorkspace` (agent-facing `manage_app install`), `configureBundle` restart, Composio eager-start, post-credential-change respawn, `ensureSourceRegistered`, and boot reload.
- **Host-resources extension capability — Phase 1 (advertisement + manifest gate).** Platform advertises `ai.nimblebrain/host-resources` in `ClientCapabilities.extensions` on every bundle's `initialize` handshake — the MCP-spec-blessed vendor-extension surface ([overview](https://modelcontextprotocol.io/extensions/overview)). Bundles declare requirements via `_meta["ai.nimblebrain/host"].host_capabilities` (keyed object mirroring the platform-side advertisement); entries with `required: true` cause install to fail loudly if the platform doesn't provide them, no silent runtime degradation. v1 payload: `read.enabled=true`, `list.enabled=true`, `write.enabled=false`, `schemes=["files"]`; `read.maxSize=10 MiB` until v2 ships range reads. Operations are object-shaped (not bare booleans) so future sub-fields slot in without breaking compat. Inbound `ai.nimblebrain/resources/{read,list}` request handlers land in Phase 2. Host-meta schema bumps to `host_version: "1.1"`.
- `auth: composio` third connector auth kind alongside `dcr` and `static`. Discriminator lives in `_meta["ai.nimblebrain/connector"].auth` on `ServerDetail` entries; the install path branches at `handleInstallRemoteOAuth`, eager-starts the source via Composio's session URL (no native MCP OAuth handshake — Composio's static `x-api-key` is the transport credential), and writes a parallel state file at `credentials/composio/<connectorId>/connection.json`. Disconnect deletes the Composio-side account (best-effort) plus the local `connection.json`. Catalog entries declare `composio.toolkit`, `composio.authConfigEnv` (env var name for the `ac_…` id), and an optional `composio.tools` allowlist.
Expand Down
13 changes: 13 additions & 0 deletions packages/bundle-sdk-py/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Python build artifacts
dist/
build/
*.egg-info/
__pycache__/
*.py[cod]
.pytest_cache/

# uv-managed virtualenv (per-developer; not committed)
.venv/

# Editor / OS
.DS_Store
34 changes: 34 additions & 0 deletions packages/bundle-sdk-py/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Changelog

## [Unreleased]

## [0.1.0]

Initial release. Wraps the `ai.nimblebrain/host-resources` MCP extension
(Phase 1 + Phase 2a in the NimbleBrain platform).

### Added

- `host(ctx)` factory + `HostResources` class.
- `HostResources.available` capability probe — reads
`ClientCapabilities.extensions["ai.nimblebrain/host-resources"]`
with a fallback to the legacy `experimental` slot for older platforms.
- `HostResources.supports_scheme(scheme)` per-scheme check against the
host's advertised allowlist.
- `HostResources.read(uri)` — wraps `ai.nimblebrain/resources/read`,
returns the MCP-standard `ReadResourceResult`.
- `HostResources.list(mime_type=..., tags=...)` — wraps
`ai.nimblebrain/resources/list` with the platform's `_meta.filter`
unwrap convention. Returns `ListResourcesResult`.
- `HostCapabilityMissing` exception for the "host doesn't advertise the
extension" case — supports the Level-C fallback pattern (catch + return
a structured tool error that teaches the agent to retry).
- Error-code constants (`RATE_LIMITED = -32004`, `RESPONSE_TOO_LARGE =
-32005`) so bundle authors don't hard-code magic numbers when matching
on `McpError.error.code`.

### Requirements

- Python 3.11+
- `fastmcp>=3.0.0`
- `mcp>=1.27.0`
125 changes: 125 additions & 0 deletions packages/bundle-sdk-py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# nimblebrain-bundle-sdk

Python SDK for NimbleBrain MCP bundles. Provides a typed wrapper around the
`ai.nimblebrain/host-resources` extension so bundle code can read workspace
files through the platform without going through the agent.

```bash
uv add nimblebrain-bundle-sdk
# or
pip install nimblebrain-bundle-sdk
```

## What it's for

A bundle running on the NimbleBrain platform receives a `Context` argument
in every tool handler. The platform advertises the
`ai.nimblebrain/host-resources` capability during the MCP `initialize`
handshake; when present, the bundle can issue
`ai.nimblebrain/resources/read` and `ai.nimblebrain/resources/list`
requests back to the platform to read files from the workspace's
`FileStore` — the same store the agent's `files__read` tool sees.

This SDK wraps that protocol. You write `await host(ctx).read(uri)` and
get bytes; the SDK takes care of capability detection, method names,
and Pydantic result types.

## Quick start

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

@mcp.tool
async def start_research(
seed_uri: str | None = None,
seed_data: str | None = None,
ctx: Context = None,
):
h = host(ctx)
if seed_uri and h.available:
# Host supports the extension — read the file directly.
result = await h.read(seed_uri)
content = result.contents[0].text
elif seed_data:
# No URI, but the agent passed inline content. Common path for
# hosts that don't (yet) advertise the extension.
content = seed_data
elif seed_uri and not h.available:
# URI passed, but the host can't resolve it. Return a structured
# tool error so the agent knows to retry with `seed_data` instead
# — the Level-C fallback pattern.
raise ValueError(
"This host doesn't support ai.nimblebrain/host-resources. "
"Pass file contents inline via `seed_data` instead."
)
else:
raise ValueError("Provide `seed_data` or `seed_uri`.")

... # do research with `content`
```

## API

```python
from nimblebrain_bundle_sdk import host

h = host(ctx)

# Capability detection — true when the platform advertised
# `ai.nimblebrain/host-resources` with `read.enabled: true`.
h.available

# Per-scheme detection. v1 only supports `files`; future schemes
# (`entities`, etc.) get added to the platform's advertisement.
h.supports_scheme("files")

# Read a single resource. Returns the MCP-standard `ReadResourceResult`.
# Raises `HostCapabilityMissing` when the host doesn't advertise the
# extension. Raises `McpError` for `-32004` (rate limited), `-32005`
# (response too large), `-32002` (resource not found), `-32602`
# (invalid params, e.g. unsupported scheme).
result = await h.read("files://fl_abc123")
text = result.contents[0].text

# List resources with an optional filter. Filter rides in `_meta.filter`
# per the platform's wire convention; this SDK does the unwrap. Supports
# `mime_type` and `tags` filters; rejects pagination cursors with
# `-32602` (pagination is reserved for a later version).
listing = await h.list(mime_type="text/csv")
for entry in listing.resources:
print(entry.name, entry.uri)
```

## Error codes

The host-resources extension uses the JSON-RPC impl-defined server-error
range (`-32000` to `-32099`) for quota/policy responses, distinct from
`-32603 InternalError`:

| Code | Meaning |
| --- | --- |
| `-32002` | Resource not found (also returned for cross-workspace lookups — no info leak) |
| `-32004` | Rate limited (per-bundle token bucket; carries `retryAfterMs` in `error.data`) |
| `-32005` | Response too large (whole-response cap; `error.data` carries `size`, `maxSize`) |
| `-32602` | Invalid params (unsupported URI scheme, malformed `tags`, unsupported cursor) |

Bundle authors should match on specific codes to back off intelligently
rather than treating all errors as server faults.

## Releases

This SDK is released independently of the NimbleBrain platform via
`bundle-sdk-py/v*` git tags. Each tag triggers a GitHub Actions workflow
that builds and publishes to PyPI.

The SDK tracks the platform's `ai.nimblebrain/host-resources` capability
shape — when the platform ships a v2 (range reads, write, etc.), the
SDK ships a matching minor version.

## Status

`v0.x` is pre-stable; the API may shift before `v1.0`. The wire
protocol is namespaced under `ai.nimblebrain/` and intentionally
shaped to be a clean rename if the extension ever upstreams to the
MCP spec.
76 changes: 76 additions & 0 deletions packages/bundle-sdk-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[project]
name = "nimblebrain-bundle-sdk"
version = "0.1.0"
description = "Python SDK for NimbleBrain MCP bundles. Lets bundles read workspace files via the ai.nimblebrain/host-resources extension."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
authors = [{ name = "NimbleBrain" }]
keywords = ["mcp", "nimblebrain", "bundle", "host-resources"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
]

dependencies = [
# FastMCP is the layer bundles build on; Context comes from there.
# >=3.0 matches what synapse-research and other production bundles
# currently pin.
"fastmcp>=3.0.0",
# The underlying MCP types (ReadResourceResult, ListResourcesResult,
# ClientCapabilities) are imported from this. FastMCP depends on it
# transitively; pinning explicitly so the SDK's import paths don't
# silently break when FastMCP's transitive resolution shifts.
#
# The upper bound is intentional. The SDK's custom-method dispatch
# (`cast(ServerRequest, ...)` in `host.py`) relies on
# `BaseSession.send_request` calling only `.model_dump()` on the
# request — a behavior verified against 1.27.x but not contractually
# guaranteed by mcp's public surface. A 2.0 release could plausibly
# tighten input validation and break us at runtime (not import time).
# Bump after verifying against the next minor release; cut a SDK
# major if the dispatch shape needs to change.
"mcp>=1.27.0,<2.0.0",
]

[project.urls]
Homepage = "https://nimblebrain.ai"
Documentation = "https://docs.nimblebrain.ai"
Repository = "https://github.com/NimbleBrainInc/nimblebrain"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/nimblebrain_bundle_sdk"]

[dependency-groups]
dev = [
"ruff>=0.13.1",
"ty>=0.0.1a1",
"pytest>=8.4.2",
"pytest-asyncio>=0.24.0",
]

[tool.ruff]
target-version = "py311"
line-length = 100

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP"]
ignore = ["E501", "B008"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
32 changes: 32 additions & 0 deletions packages/bundle-sdk-py/src/nimblebrain_bundle_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Python SDK for NimbleBrain MCP bundles.

Wraps the `ai.nimblebrain/host-resources` extension so bundle code can
read workspace files through the platform without filesystem access.
"""

from nimblebrain_bundle_sdk.errors import HostCapabilityMissing
from nimblebrain_bundle_sdk.host import HostResources, host
from nimblebrain_bundle_sdk.methods import (
HOST_RESOURCES_CAPABILITY_KEY,
HOST_RESOURCES_LIST_METHOD,
HOST_RESOURCES_READ_METHOD,
INVALID_PARAMS,
RATE_LIMITED,
RESOURCE_NOT_FOUND,
RESPONSE_TOO_LARGE,
)

__all__ = [
"HOST_RESOURCES_CAPABILITY_KEY",
"HOST_RESOURCES_LIST_METHOD",
"HOST_RESOURCES_READ_METHOD",
"INVALID_PARAMS",
"RATE_LIMITED",
"RESOURCE_NOT_FOUND",
"RESPONSE_TOO_LARGE",
"HostCapabilityMissing",
"HostResources",
"host",
]

__version__ = "0.1.0"
Loading