Metadata-Version: 2.4
Name: seren-workbench
Version: 5.1.0
Summary: MCP server for the Seren stack — the tool surface LLMs reach through.
Author: Chad Roesler
License: AGPL-3.0-or-later
Project-URL: Homepage, https://github.com/ChadRoesler/SerenWorkbench
Project-URL: Repository, https://github.com/ChadRoesler/SerenWorkbench
Project-URL: Bug Tracker, https://github.com/ChadRoesler/SerenWorkbench/issues
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp<2,>=1.3.0
Requires-Dist: fastapi>=0.110
Requires-Dist: uvicorn[standard]>=0.29
Requires-Dist: httpx>=0.28
Requires-Dist: pyyaml>=6.0
Requires-Dist: seren-meninges<6,>=2.4.0
Requires-Dist: seren-sinew<6,>=1.11.0
Provides-Extra: updates
Provides-Extra: corp
Requires-Dist: truststore>=0.9; extra == "corp"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Provides-Extra: peers
Requires-Dist: seren-memory>=5.0; extra == "peers"
Requires-Dist: seren-loci>=5.0; extra == "peers"
Requires-Dist: seren-corpus-callosum>=5.0; extra == "peers"
Requires-Dist: seren-hippocampus>=5.0; extra == "peers"
Requires-Dist: seren-lodestar>=5.0; extra == "peers"
Dynamic: license-file

# SerenWorkbench

The tool surface an LLM reaches through.

One process on port **7425** serving an MCP endpoint, an operator dashboard and a small HTTP
API. It is the one place a model connects: the standard Seren components —
Memory, Loci, the Corpus Callosum, the Hippocampus and Lodestar — are **passed
through**, so the Workbench offers each one's own tools exactly as that service
defines them, with a switch per component (`components:` in the config,
`GET /components` to see what each is offering). Builtins cover web search,
time and the cluster. On top of those, you can add your own tools as **YAML
files** — no Python, no restart.

Part of the [Seren](https://github.com/ChadRoesler) stack, but it doesn't
require the rest of it. Point it at whichever services you actually run.

---

## Install

```bash
pip install seren-workbench
python -m seren_workbench
```

That's a working server with the builtin tools and an empty tool
directory. Configuration is optional — copy `seren-workbench.yaml.sample`
to `seren-workbench.yaml` when you want to change ports, service URLs or
which tools start enabled.

```bash
python -m seren_workbench --config /etc/seren/workbench.yaml --port 7425
```

## Connect a client

The MCP endpoint is at `/mcp/` (trailing slash — a bare `/mcp` gets a 307).

```jsonc
{
  "mcpServers": {
    "seren-workbench": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:7425/mcp/",
               "--transport", "http-only"]
    }
  }
}
```

Set a bearer token in the config (or `SEREN_WORKBENCH_BEARER_TOKEN`) before
this leaves localhost - a bind beyond loopback with no token refuses to start
and prints the ways out.

If Memory or Lodestar were installed with a token, tell the builtins what to
present:

```yaml
services:
  bearer_token_env: SEREN_CLUSTER_TOKEN        # one token for memory / lodestar / scheduler
  # memory_bearer_token_env: SEREN_MEMORY_TOKEN  # or per service
```

---

## Adding your own tools

Drop a YAML file in `tools_dir` (default `~/seren-workbench/tools`):

```yaml
schema_version: 1
tools:
  - name: disk_free
    description: >
      Report free space on the Seren data volume. Use when someone asks
      whether there's room for another model.
    invoke:
      kind: process
      argv: ["df", "-h", "--", "{mount_point}"]
    parameters:
      - name: mount_point
        type: string
        required: true
        pattern: "/[A-Za-z0-9._/-]*"
```

Then reload — the tool is live immediately, no restart:

```bash
curl -X POST localhost:7425/tools/manifests/reload
```

Tools can run a **process** or make a **web** call. Parameters are typed,
defaulted, range-checked and — for strings — matched against a `pattern`
or an `enum`, with the rules published into the JSON schema so the model
reads them rather than discovering them by being corrected.

**→ [docs/TOOL-MANIFESTS.md](https://github.com/ChadRoesler/SerenWorkbench/blob/main/SerenWorkbench/docs/TOOL-MANIFESTS.md)**
is the full reference.
**[examples/tools/example-tool.yaml](https://github.com/ChadRoesler/SerenWorkbench/blob/main/SerenWorkbench/examples/tools/example-tool.yaml)**
is an annotated file you can copy; the test suite loads it, so it can't
quietly stop being true.

<!-- Absolute URLs on purpose: this file is the PyPI long_description, and
     PyPI does not resolve relative links. -->


### Tools the model asks for

A model can propose a tool for itself with `propose_tool`. The manifest lands
in a staging directory that **nothing loads** — it's inert text until you read
it and approve, at which point it moves into `tools/` and goes live.

Open `/viewer` → **Proposals**; the tab shows a count when something's waiting.
Or from the shell:

```bash
curl -s localhost:7425/proposals                     # what's waiting
curl -s localhost:7425/proposals/prop_a1b2c3d4e5     # the manifest + what it would run
curl -X POST localhost:7425/proposals/prop_a1b2c3d4e5/approve
curl -X POST localhost:7425/proposals/prop_a1b2c3d4e5/reject \
     -d '{"critique":"argv shells out to a script I cannot read"}'
```

**Approving installs it switched off.** The tool appears in your list,
disabled; you turn it on separately from the Tool State tab. Two decisions —
"this isn't malicious" and "I want it live now" — kept apart on purpose.

The model can propose and read its proposals. It cannot approve — there's no
MCP tool behind any approval route, and a test asserts there never is. Reject
requires a critique, which the proposer reads and can revise against; same
loop as the memory consolidator's draft gate.

Turn it off with `dashboard.proposals_enabled: false`, which removes the tools
entirely rather than leaving them to fail.

A model can ask for a whole **MCP server** the same way, with `propose_plugin`:
an address and a reason. Nothing is dialled until you approve; then the server
is plugged in and every tool it offers arrives switched off, for you to turn on
one at a time. A proposal never carries a token — it can only name where one is
kept on this box, and the review says in so many words that approving sends it.

**→ [docs/TOOL-PROPOSALS.md](https://github.com/ChadRoesler/SerenWorkbench/blob/main/SerenWorkbench/docs/TOOL-PROPOSALS.md)** — what to look at when reviewing one.

A model can also propose a **project**: a piece of work with a rendered plan,
the one directory it may touch, an explicit list of sites it may fetch from,
and the tools it will need, each of which is its own proposal under it.
Approving a project agrees to the plan and the limits and turns nothing on; a
**revision** of an agreed project is its own proposal, shown as a diff.

**→ [docs/PROJECT-PROPOSALS.md](https://github.com/ChadRoesler/SerenWorkbench/blob/main/SerenWorkbench/docs/PROJECT-PROPOSALS.md)** — projects and revisions.

### What reload won't do

It reads files that are already on disk. It cannot author a tool, it
refuses a manifest that would shadow a builtin, it won't re-enable
something you switched off, and it treats a *missing* tools directory as
"something's wrong" rather than "delete everything." A tool exists because
a person put it somewhere.

Neither does a restart re-enable anything: toggles are remembered in
`<tools_dir>/.tool-state.json`. A remote `from:` import may only hand you
`kind: web` tools, and no tool may point back at the Workbench itself.

---

## What a connected model can count on

- **The list it has is the list there is.** The Workbench tells connected
  clients when its tools change (`notifications/tools/list_changed`): a
  component that came up late, a switch you flipped, a proposal you approved.
  A tool that is switched off is not offered at all, and appears the moment
  it is switched on.
- **A failure is an error.** A builtin that could not do what was asked comes
  back as an MCP error, not as a successful result whose text is bad news.
- **It can read its own record.** `list_my_tool_calls` is the audit log from
  the caller's side: which tools, when, whether they worked. Names and
  outcomes only — never an argument or a result.
- **A service it asks for stays up while it is using it.**
  `ensure_service_running` takes a lease with Lodestar and `release_service`
  gives it back; the service is stopped when its last holder lets go. A hold
  a session forgot is let go by the Workbench (`tools.ensure_service_running.
  hold_minutes`, and at shutdown). `GET /components` shows what is held.

## Snapshots

The tools folder — manifests, approved plugins, proposals, and the switches —
is what a Workbench keeps, and it is snapshotted like the rest of the Seren
family: daily by default, into `backups/` beside the tools folder.

```bash
curl -s localhost:7425/stores                                  # what is kept, and the snapshots
curl -X POST localhost:7425/stores/snapshot -d '{"reason":"before I tidy up"}'
curl -X POST localhost:7425/stores/snapshots/<id>/rehearse     # a restore's dry run
```

Putting one back is two config keys and a restart, into an empty tools folder
only — there is no route and no tool that restores:

```yaml
backup:
  restore_from: /path/to/backups/seren-workbench/20261006-151256
  restore_reason: "moving to the new box"
```

---

## Endpoints

| | |
|---|---|
| `GET /` | service info, tool counts and update status |
| `GET /health` | liveness |
| `GET /tools` | every tool with its schema |
| `GET`/`POST` `/tools/state` | enable and disable, per tool or per action |
| `GET /tools/manifests` | what loaded, what was skipped, and why |
| `POST /tools/manifests/reload` | re-read the directory, apply it live |
| `GET /components` | each component and plugin: on or off, reachable or why not; what is held |
| `POST /components/state` | switch a component or plugin on or off |
| `GET /proposals` | tools and plugins the model has asked for |
| `GET /proposals/{id}` | one, with the full manifest and what it would run |
| `POST /proposals/{id}/approve` | install it, switched off |
| `POST /proposals/{id}/reject` | refuse it, with a critique |
| `GET /config` | resolved config, secrets masked |
| `GET /logs` | recent server logs |
| `GET /stores` | what the Workbench keeps, and its snapshots |
| `POST /stores/snapshot` | take a snapshot now |
| `POST /stores/snapshots/{id}/rehearse` | prove one can be put back, without putting it back |
| `/mcp/` | the MCP streamable-HTTP transport |
| `/viewer` | the operator dashboard |

## Update checking

`GET /` reports whether a newer `seren-workbench` has been published, alongside
the tool counts:

```jsonc
{
  "service": "SerenWorkbench",
  "version": "1.2.0",
  "tools_count": 26,
  "updates": {
    "status": "ok",
    "installed": "1.2.0",
    "latest": "1.3.0",
    "update_available": true
  }
}
```

On by default: update checking is part of the core install (it lives in
`seren-meninges`). A box that never leaves the LAN has no business calling
PyPI; set `enabled: false` there. The knobs:

```yaml
updates:
  enabled: true
  check_interval_hours: 6      # cached; never checked per-request
  allow_prerelease: false
```

The result is cached and the check never happens in the request path, so `/`
stays fast. `updates.status` is always one of `ok`, `disabled`, `unavailable`
(a broken install: the checker's dependencies are missing) or `error`
— **never absent, and never a silent "you're fine" when it couldn't actually
check.**
`SEREN_WORKBENCH_UPDATES_ENABLED=false` turns it off without editing config.

**The Workbench never upgrades itself.** Applying an update is `pip install -U
seren-workbench` and a restart — your service supervisor's job, not this
process's. A running process can't reliably swap its own code out from under
itself, so it doesn't pretend to.

## Development

```bash
pip install -e ".[dev]"
pytest -q
```

## License

AGPL-3.0-or-later.
