Skip to content
Draft

MCP #3137

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
29 changes: 20 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ StartOS is an open-source Linux distribution for running personal servers. It ma
- Frontend: Angular 21 + TypeScript + Taiga UI 5
- Container runtime: Node.js/TypeScript with LXC
- Database/State: Patch-DB (git submodule) - storage layer with reactive frontend sync
- API: JSON-RPC via rpc-toolkit (see `core/rpc-toolkit.md`)
- API: JSON-RPC via rpc-toolkit (see `core/rpc-toolkit.md`), MCP for LLM agents (see `core/mcp/ARCHITECTURE.md`)
- Auth: Password + session cookie, public/private key signatures, local authcookie (see `core/src/middleware/auth/`)

## Project Structure
Expand All @@ -28,7 +28,7 @@ StartOS is an open-source Linux distribution for running personal servers. It ma

## Components

- **`core/`** — Rust backend daemon. Produces a single binary `startbox` that is symlinked as `startd` (main daemon), `start-cli` (CLI), `start-container` (runs inside LXC containers), `registrybox` (package registry), and `tunnelbox` (VPN/tunnel). Handles all backend logic: RPC API, service lifecycle, networking (DNS, ACME, WiFi, Tor, WireGuard), backups, and database state management. See [core/ARCHITECTURE.md](core/ARCHITECTURE.md).
- **`core/`** — Rust backend daemon. Produces a single binary `startbox` that is symlinked as `startd` (main daemon), `start-cli` (CLI), `start-container` (runs inside LXC containers), `registrybox` (package registry), and `tunnelbox` (VPN/tunnel). Handles all backend logic: RPC API, MCP server for LLM agents, service lifecycle, networking (DNS, ACME, WiFi, Tor, WireGuard), backups, and database state management. See [core/ARCHITECTURE.md](core/ARCHITECTURE.md).

- **`web/`** — Angular 21 + TypeScript workspace using Taiga UI 5. Contains three applications (admin UI, setup wizard, VPN management) and two shared libraries (common components/services, marketplace). Communicates with the backend exclusively via JSON-RPC. See [web/ARCHITECTURE.md](web/ARCHITECTURE.md).

Expand All @@ -53,13 +53,13 @@ Rust (core/)

Key make targets along this chain:

| Step | Command | What it does |
|---|---|---|
| 1 | `cargo check -p start-os` | Verify Rust compiles |
| 2 | `make ts-bindings` | Export ts-rs types → rsync to SDK |
| 3 | `cd sdk && make baseDist dist` | Build SDK packages |
| 4 | `cd web && npm run check` | Type-check Angular projects |
| 5 | `cd container-runtime && npm run check` | Type-check runtime |
| Step | Command | What it does |
| ---- | --------------------------------------- | --------------------------------- |
| 1 | `cargo check -p start-os` | Verify Rust compiles |
| 2 | `make ts-bindings` | Export ts-rs types → rsync to SDK |
| 3 | `cd sdk && make baseDist dist` | Build SDK packages |
| 4 | `cd web && npm run check` | Type-check Angular projects |
| 5 | `cd container-runtime && npm run check` | Type-check runtime |

**Important**: Editing `sdk/base/lib/osBindings/*.ts` alone is NOT sufficient — you must rebuild the SDK bundle (step 3) before web/container-runtime can see the changes.

Expand Down Expand Up @@ -90,6 +90,17 @@ StartOS uses Patch-DB for reactive state synchronization:

This means the UI is always eventually consistent with the backend — after any mutating API call, the frontend waits for the corresponding PatchDB diff before resolving, so the UI reflects the result immediately.

## MCP Server (LLM Agent Interface)

StartOS includes an [MCP](https://modelcontextprotocol.io/) (Model Context Protocol) server at `/mcp`, enabling LLM agents to discover and invoke the same operations available through the UI and CLI. The MCP server runs inside the StartOS server process alongside the RPC API.

- **Tools**: Every RPC method is exposed as an MCP tool with LLM-optimized descriptions and JSON Schema inputs. Agents call `tools/list` to discover what's available and `tools/call` to invoke operations.
- **Resources**: System state is exposed via MCP resources backed by Patch-DB. Agents subscribe to `startos:///public` and receive debounced revision diffs over SSE, maintaining a local state cache without polling.
- **Auth**: Same session cookie auth as the UI — no separate credentials.
- **Transport**: MCP Streamable HTTP — POST for requests, GET for SSE notification stream, DELETE for session teardown.

See [core/ARCHITECTURE.md](core/ARCHITECTURE.md#mcp-server) for implementation details.

## Further Reading

- [core/ARCHITECTURE.md](core/ARCHITECTURE.md) — Rust backend architecture
Expand Down
10 changes: 10 additions & 0 deletions core/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The crate produces a single binary `startbox` that is symlinked under different
- `src/context/` — Context types (RpcContext, CliContext, InitContext, DiagnosticContext)
- `src/service/` — Service lifecycle management with actor pattern (`service_actor.rs`)
- `src/db/model/` — Patch-DB models (`public.rs` synced to frontend, `private.rs` backend-only)
- `src/mcp/` — MCP server for LLM agents (see [MCP Server](#mcp-server) below)
- `src/net/` — Networking (DNS, ACME, WiFi, Tor via Arti, WireGuard)
- `src/s9pk/` — S9PK package format (merkle archive)
- `src/registry/` — Package registry management
Expand All @@ -38,16 +39,19 @@ See [rpc-toolkit.md](rpc-toolkit.md) for full handler patterns and configuration
Patch-DB provides diff-based state synchronization. Changes to `db/model/public.rs` automatically sync to the frontend.

**Key patterns:**

- `db.peek().await` — Get a read-only snapshot of the database state
- `db.mutate(|db| { ... }).await` — Apply mutations atomically, returns `MutateResult`
- `#[derive(HasModel)]` — Derive macro for types stored in the database, generates typed accessors

**Generated accessor types** (from `HasModel` derive):

- `as_field()` — Immutable reference: `&Model<T>`
- `as_field_mut()` — Mutable reference: `&mut Model<T>`
- `into_field()` — Owned value: `Model<T>`

**`Model<T>` APIs** (from `db/prelude.rs`):

- `.de()` — Deserialize to `T`
- `.ser(&value)` — Serialize from `T`
- `.mutate(|v| ...)` — Deserialize, mutate, reserialize
Expand All @@ -63,6 +67,12 @@ See [i18n-patterns.md](i18n-patterns.md) for internationalization key convention

See [core-rust-patterns.md](core-rust-patterns.md) for common utilities (Invoke trait, Guard pattern, mount guards, Apply trait, etc.).

## MCP Server

The MCP (Model Context Protocol) server at `src/mcp/` exposes the StartOS RPC API to LLM agents via the Streamable HTTP transport at `/mcp`. Tools wrap the existing RPC handlers; resources expose Patch-DB state with debounced SSE subscriptions; auth reuses the UI session cookie.

See [src/mcp/ARCHITECTURE.md](src/mcp/ARCHITECTURE.md) for transport details, session lifecycle, tool dispatch, resource subscriptions, CORS, and body size limits.

## Related Documentation

- [rpc-toolkit.md](rpc-toolkit.md) — JSON-RPC handler patterns
Expand Down
10 changes: 5 additions & 5 deletions core/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,13 @@ pub struct SubscribeRes {
pub guid: Guid,
}

struct DbSubscriber {
rev: u64,
sub: UnboundedReceiver<Revision>,
sync_db: watch::Receiver<u64>,
pub(crate) struct DbSubscriber {
pub(crate) rev: u64,
pub(crate) sub: UnboundedReceiver<Revision>,
pub(crate) sync_db: watch::Receiver<u64>,
}
impl DbSubscriber {
async fn recv(&mut self) -> Option<Revision> {
pub(crate) async fn recv(&mut self) -> Option<Revision> {
loop {
tokio::select! {
rev = self.sub.recv() => {
Expand Down
52 changes: 52 additions & 0 deletions core/src/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use tracing::instrument;
use ts_rs::TS;

use crate::context::{CliContext, RpcContext};
use crate::registry::asset::BufferedHttpSource;
use crate::db::model::package::{ManifestPreference, PackageStateMatchModelRef};
use crate::prelude::*;
use crate::progress::{FullProgress, FullProgressTracker, PhasedProgressBar};
Expand Down Expand Up @@ -285,6 +286,57 @@ pub async fn sideload(
Ok(SideloadResponse { upload, progress })
}

#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SideloadUrlParams {
#[ts(type = "string")]
url: Url,
}

#[instrument(skip_all)]
pub async fn sideload_url(
ctx: RpcContext,
SideloadUrlParams { url }: SideloadUrlParams,
) -> Result<(), Error> {
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::new(
eyre!("URL scheme must be http or https, got: {}", url.scheme()),
ErrorKind::InvalidRequest,
));
}

let progress_tracker = FullProgressTracker::new();
let download_progress = progress_tracker.add_phase("Downloading".into(), Some(100));
let client = ctx.client.clone();
let db = ctx.db.clone();
let pt_ref = progress_tracker.clone();

let download = ctx
.services
.install(
ctx.clone(),
|| async move {
let source = BufferedHttpSource::new(client, url, download_progress).await?;
let key = db.peek().await.into_private().into_developer_key();
crate::s9pk::load(source, || Ok(key.de()?.0), Some(&pt_ref)).await
},
None,
None::<Never>,
Some(progress_tracker),
)
.await?;

tokio::spawn(async move {
if let Err(e) = async { download.await?.await }.await {
tracing::error!("Error sideloading package from URL: {e}");
tracing::debug!("{e:?}");
}
});

Ok(())
}

#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[group(skip)]
#[ts(export)]
Expand Down
7 changes: 7 additions & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub mod init;
pub mod install;
pub mod logs;
pub mod lxc;
pub mod mcp;
pub mod middleware;
pub mod net;
pub mod notifications;
Expand Down Expand Up @@ -441,6 +442,12 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.with_metadata("get_session", Value::Bool(true))
.no_cli(),
)
.subcommand(
"sideload-url",
from_fn_async(install::sideload_url)
.with_metadata("sync_db", Value::Bool(true))
.no_cli(),
)
.subcommand(
"install",
from_fn_async_local(install::cli_install)
Expand Down
Loading