Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dd0b904
fix(python): add missing 'import os' in transport.py
msilverblatt Mar 21, 2026
372f8b0
fix(rust): add missing clear functions and fix test isolation
msilverblatt Mar 21, 2026
3f653af
fix(tool-groups): change default strategy from 'union' to 'separate'
msilverblatt Mar 21, 2026
d19b2f8
fix(go-sdk): enable external consumption via go.work and submodule tags
msilverblatt Mar 21, 2026
214a465
fix(reload): watch entire directory tree instead of single file
msilverblatt Mar 21, 2026
25dd0cd
fix(bridge): remove stale tools/resources/prompts on sync
msilverblatt Mar 21, 2026
2be3752
fix(version): inject version via ldflags instead of hardcoded 1.0.0
msilverblatt Mar 21, 2026
30ca5c9
test(e2e): add tool group separate strategy test
msilverblatt Mar 21, 2026
5876dd1
test(e2e): add multi-step workflow integration test
msilverblatt Mar 21, 2026
9633ebc
test(e2e): add middleware integration test (audit + arg injection)
msilverblatt Mar 21, 2026
b126f43
test(e2e): add sidecar with health check integration test
msilverblatt Mar 21, 2026
49f0d00
test(e2e): add hot reload test verifying tool add/remove
msilverblatt Mar 21, 2026
6336bf9
fix(typescript): update group tests for separate default strategy
msilverblatt Mar 21, 2026
223d285
fix(reload): auto-watch newly created subdirectories
msilverblatt Mar 21, 2026
680e4d5
fix(rust): release GROUP_REGISTRY lock before calling handlers
msilverblatt Mar 21, 2026
8871c12
fix(bridge): add SyncAll for atomic tool/resource/prompt sync
msilverblatt Mar 21, 2026
ef8c015
fix(test): use random port for sidecar E2E test
msilverblatt Mar 21, 2026
b496fc5
fix(test): use incrementing request IDs in E2E helpers
msilverblatt Mar 21, 2026
688dd5a
fix(bridge): log and handle syncResources partial failures
msilverblatt Mar 21, 2026
44effa8
fix(ci): force-create SDK tag to handle re-runs
msilverblatt Mar 21, 2026
070c1a0
fix(go-sdk): gitignore go.work files — dev-only workspace
msilverblatt Mar 21, 2026
15c5fbb
fix(test): use atomic request IDs in SendRequest helper
msilverblatt Mar 21, 2026
fd8727f
fix(bridge): don't skip template sync when resource listing fails
msilverblatt Mar 21, 2026
5c5b759
fix(rust): mark dispatch_group_action as test-only
msilverblatt Mar 21, 2026
c0d9d7d
fix(reload): recursively walk newly created subdirectory trees
msilverblatt Mar 21, 2026
5e58819
fix(reload): restart process instead of sending ReloadRequest
msilverblatt Mar 21, 2026
da7b659
docs: update README for separate default tool group strategy
msilverblatt Mar 21, 2026
7118732
docs: rewrite hot-reload guide to match process-restart behavior
msilverblatt Mar 21, 2026
90067e2
chore: bump all SDK versions to 0.3.0
msilverblatt Mar 21, 2026
7e303f1
fix: keep Go SDK at v0.2.0 — CI updates version refs at release time
msilverblatt Mar 21, 2026
9e3dba2
Merge remote-tracking branch 'origin/master' into fix/critical-fixes-…
msilverblatt Mar 21, 2026
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
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Create SDK Go submodule tag
if: success()
run: |
TAG="${GITHUB_REF#refs/tags/}"
git tag -f "sdk/go/${TAG}"
git push origin "sdk/go/${TAG}" --force

pypi:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ __pycache__/
.superpowers/
docs/superpowers/
sdk/rust/target/
go.work
go.work.sum
/protomcp
docs/.astro/
2 changes: 2 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ version: 2
builds:
- main: ./cmd/protomcp
binary: pmcp
ldflags:
- -s -w -X main.version={{.Version}}
goos:
- linux
- darwin
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ See the [full documentation](https://msilverblatt.github.io/protomcp/) for detai

## Tool Groups

Real-world MCP tools tend to accumulate dozens of parameters behind a single endpoint. Tool groups let you split actions into clean, per-action schemas while exposing a single tool with a discriminated union (`oneOf`) to the LLM.
Real-world MCP tools tend to accumulate dozens of parameters behind a single endpoint. Tool groups let you split actions into clean, per-action schemas. By default, each action becomes its own tool (e.g. `db.query`, `db.insert`) — the **separate** strategy. For clients that support `oneOf` schemas, the **union** strategy is available as an opt-in, exposing all actions as a single tool with a discriminated union.

**Before** -- one tool, 20+ params:

Expand Down Expand Up @@ -407,7 +407,7 @@ See [`examples/`](examples/) for runnable demos:
- **Basic** — minimal tool examples in all four languages
- **Resources & Prompts** — resources, prompts, completions, and tools together
- **Full showcase** — structured output, progress, cancellation, dynamic tool lists, error handling
- **Tool Groups** — per-action schemas with union and separate strategies
- **Tool Groups** — per-action schemas with separate (default) and union strategies
- **Advanced Server** — middleware, telemetry, server context working together
- **Workflows** — deployment pipeline as a server-defined state machine

Expand Down
16 changes: 8 additions & 8 deletions cmd/protomcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"time"

Expand All @@ -23,6 +24,8 @@ import (
"github.com/msilverblatt/protomcp/internal/validate"
)

var version = "dev"

func main() {
cfg, err := config.Parse(os.Args[1:])
if err != nil {
Expand Down Expand Up @@ -109,7 +112,7 @@ func main() {
backend := &toolBackend{pm: pm, tlm: tlm, allTools: tools}

// 5. Create bridge (replaces custom mcp.NewHandler)
b := bridge.New(backend, logger)
b := bridge.New(backend, logger, version)
b.SetToolListMutationHandler(func(enable, disable []string) {
if len(enable) > 0 {
tlm.Enable(enable)
Expand All @@ -121,9 +124,7 @@ func main() {
})

// 6. Sync tools, resources, and prompts from backend into the official mcp.Server
b.SyncTools()
b.SyncResources()
b.SyncPrompts()
b.SyncAll()

// 7. Wire process manager callbacks
pm.OnProgress(func(msg *pb.ProgressNotification) {
Expand All @@ -145,7 +146,8 @@ func main() {

// 8. Start file watcher (dev mode only)
if cfg.Command == "dev" {
w, err := reload.NewWatcher(cfg.File, nil, func() {
ext := filepath.Ext(cfg.File)
w, err := reload.NewWatcher(filepath.Dir(cfg.File), []string{ext}, func() {
slog.Info("file changed, reloading...")
newTools, err := pm.Reload(ctx)
if err != nil {
Expand All @@ -163,9 +165,7 @@ func main() {
if !slicesEqual(oldActive, newActive) {
slog.Info("tool list changed, syncing tools")
}
b.SyncTools()
b.SyncResources()
b.SyncPrompts()
b.SyncAll()
})
if err != nil {
slog.Error("failed to create file watcher", "error", err)
Expand Down
89 changes: 16 additions & 73 deletions docs/src/content/docs/guides/hot-reload.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ description: How protomcp reloads tool processes when source files change.

## How it works

protomcp watches your tool file for changes. When a change is detected:
`pmcp dev` watches the directory containing your entry-point file, recursively. When any matching file changes:

1. A `ReloadRequest` is sent to the tool process over the unix socket
2. The tool process handles the reload (re-registers tools, re-reads config, etc.)
3. The tool process replies with `ReloadResponse { success: true }`
4. protomcp sends `notifications/tools/list_changed` to the MCP host
5. The MCP host re-fetches the tool list

The file is the same process — no restart, no re-connection.
1. The tool process is killed
2. Internal state (tools, resources, prompts) is reset
3. A fresh process is spawned
4. The new process performs a full handshake, registering its tools from scratch
5. pmcp diffs the new tool list against the old one and sends `notifications/tools/list_changed` if anything changed
6. Stale tools, resources, and prompts that no longer exist are automatically removed

---

Expand All @@ -32,84 +31,28 @@ pmcp dev tools.ts

It is disabled in `run` mode, which is intended for production.

The file watcher monitors the tool file path specified on the command line. Changes to imported modules are not automatically detected — only the entry file.
The watcher monitors the entire directory tree rooted at the entry file's parent directory. Changes to imported modules are detected automatically as long as they live under that directory.

---

## Reload modes

### Default: graceful reload
## File watching details

```sh
# Python
pmcp dev tools.py

# TypeScript
pmcp dev tools.ts
```

Sends `ReloadRequest` to the running process. The SDK handles this by re-executing the module-level code and re-registering all tools.

### Immediate: process restart

```sh
# Python
pmcp dev tools.py --hot-reload immediate

# TypeScript
pmcp dev tools.ts --hot-reload immediate
```

Kills and restarts the tool process. Useful when:
- The language or runtime doesn't support graceful reload
- You want a clean slate every time (no stale module cache)
- You're using `.go` or `.rs` files that need recompilation
- **Debounce**: there is a 100ms debounce — rapid saves (e.g. editor auto-format on write) trigger only one reload.
- **New directories**: subdirectories created while dev mode is running are automatically watched.
- **Skipped directories**: `.git`, `node_modules`, `__pycache__`, `target`, and `venv` are ignored.

---

## In-flight calls

Calls that are in flight when a reload is triggered are not interrupted. protomcp waits for in-flight calls to complete before applying the reload. If a reload is requested while calls are running, it is queued.

---

## SDK behavior on reload

The Python and TypeScript SDKs re-run the tool registration code automatically. Since `@tool()` and `tool()` append to a global registry, the SDK clears the registry before re-running.

If you have initialization code that should only run once (e.g. loading a model, connecting to a database), gate it with a module-level flag:

```python
from protomcp import tool, ToolResult

_db = None

def _get_db():
global _db
if _db is None:
_db = connect_to_db()
return _db

@tool("Query the database")
def query(sql: str) -> ToolResult:
results = _get_db().execute(sql)
return ToolResult(result=str(results))
```

The database connection is created on first use and reused across reloads.
Calls that are in flight when a change is detected are interrupted — the process is killed immediately. If you need a call to complete before the process exits, that is not supported in dev mode.

---

## Gotchas

**Module-level side effects**: Code at module level runs on every reload. Avoid expensive operations (network calls, model loading) at module level without caching.

**File-level watch only**: Only the entry file is watched. If you change an imported module, touch the entry file to trigger reload:

```sh
touch tools.py
```
**State is lost on every reload**: Because the process is killed and restarted, all in-memory state (caches, sessions, open connections) is lost. Re-initialize lazily if you need to survive across reloads.

**Immediate mode loses state**: With `--hot-reload immediate`, the process is killed and restarted. Any in-memory state (caches, sessions, etc.) is lost.
**Whole directory is watched**: Every file under the entry file's parent directory (filtered by extension) can trigger a reload. If your project directory contains generated or frequently-updated files, move them outside the watched tree or use a subdirectory structure where the entry file lives higher up.

**Syntax errors**: If the tool file has a syntax error after reload, the `ReloadResponse` will contain `success: false` and an error message. The previous tool list remains active.
**Syntax errors at startup**: If the tool process fails to start or crashes during the handshake, pmcp will log the error and leave the previous tool list active until the next successful reload.
11 changes: 1 addition & 10 deletions examples/go/resources_and_prompts/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@ module resources-and-prompts-example

go 1.25.6

require github.com/msilverblatt/protomcp/sdk/go v0.0.0

require (
github.com/klauspost/compress v1.18.4 // indirect
github.com/msilverblatt/protomcp v0.0.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

replace (
github.com/msilverblatt/protomcp => ../../../
github.com/msilverblatt/protomcp/sdk/go => ../../../sdk/go
github.com/msilverblatt/protomcp/sdk/go v0.2.0
)
9 changes: 3 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
module github.com/msilverblatt/protomcp

go 1.25.0
go 1.25.6

require (
github.com/fsnotify/fsnotify v1.9.0
github.com/klauspost/compress v1.18.4
github.com/modelcontextprotocol/go-sdk v1.4.1
golang.org/x/net v0.52.0
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
nhooyr.io/websocket v1.8.17
)

require (
Expand All @@ -18,7 +17,5 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
nhooyr.io/websocket v1.8.17 // indirect
golang.org/x/tools v0.42.0 // indirect
)
32 changes: 0 additions & 32 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
Expand All @@ -26,34 +16,12 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
Expand Down
Loading
Loading