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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ dist
dist-ssr
*.local

# Local MCP config (copy src-tauri/mcp.example.json → src-tauri/mcp.json)
src-tauri/mcp.json

# Editor directories and files
.vscode/*
!.vscode/extensions.json
Expand Down
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions doc/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ Telegram ──► teloxide dispatcher ──► text_handler

---

## Modules

- [MCP — Model Context Protocol](./mcp.md) — agent tool-use via external MCP servers (POC).

---

## Adding a New Module

### Frontend
Expand Down
112 changes: 112 additions & 0 deletions doc/design/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# MCP — Model Context Protocol Module (POC)

> Status: **proof of concept**. One server, one transport, one happy path.

## What & Why

[MCP](https://modelcontextprotocol.org/) is an open JSON-RPC 2.0 protocol that lets an LLM "host" discover and call tools exposed by external "servers". Pengine adopts MCP so we can grow the agent's capabilities by dropping in new servers instead of writing bespoke Rust glue for each tool. Every tool call flows through one well-defined choke point, which is what makes it auditable.

## Roles in Pengine

| Role | Where | Responsibility |
|---|---|---|
| **Host** | Pengine (Tauri binary) | Owns the LLM (Ollama) connection, the Telegram bot, and the agent loop. |
| **Client** | `src-tauri/src/modules/mcp/` | One `McpClient` per connected server. Speaks JSON-RPC over stdio. |
| **Server** | External child process | Anything that speaks MCP — `npx @modelcontextprotocol/server-filesystem`, a Docker container, a custom binary. |

```text
Telegram message
bot::service::text_handler
bot::agent::run_turn ────► ollama::chat_with_tools (Ollama /api/chat)
▲ │
│ │ tool_calls?
│ ▼
└─────────── mcp::registry::call_tool ──► McpClient ──► child process (stdio)
```

## Module Layout

```text
src-tauri/src/modules/mcp/
├── mod.rs
├── protocol.rs JSON-RPC 2.0 request/response types
├── types.rs McpConfig, ServerConfig, Tool
├── transport.rs StdioTransport — child process + line-delimited JSON
├── client.rs McpClient — initialize / tools/list / tools/call
├── registry.rs McpRegistry — fan-out across all connected servers
└── service.rs load_or_init_config(), connect_all()
```

The registry lives on `AppState.mcp` (`Arc<RwLock<McpRegistry>>`) so the bot agent and any future HTTP route can reach it.

## Config

File: `$APP_DATA/mcp.json` (next to `connection.json`). Created on first launch with a sane default if missing.

```json
{
"servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {}
}
}
}
```

To add a server: add another entry under `servers`. Restart Pengine.

## Protocol Subset Implemented

Four messages, that's it:

1. `initialize` — handshake. We send `{protocolVersion, capabilities, clientInfo}` and ignore most of the response.
2. `notifications/initialized` — required notification after init.
3. `tools/list` — discovery, cached on the client.
4. `tools/call` — `{name, arguments}` → `{content: [{type: "text", text}]}`.

Out of scope for the POC: resources, prompts, sampling, server-initiated requests, batch JSON-RPC, HTTP transport.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Ollama Bridge

MCP `inputSchema` is JSON Schema, and so are Ollama's tool `parameters` — translation is just a rename. See `to_ollama_tools` in `bot/agent.rs`. Tool names are emitted as `server.tool` so the registry can route a call back to the right client.

The agent loop in `bot::agent::run_turn`:

1. Snapshot the available tools from the registry.
2. Send `system + user` plus the tool list to Ollama.
3. If the response carries `tool_calls`, run each via `registry.call_tool`, append the results as `role: "tool"` messages, loop. Capped at **5 steps**.
4. Otherwise return the assistant content as the final reply.

Use a tool-capable model (e.g. `qwen3:8b`). Check with `ollama show <model>` for the `tools` capability.

## Audit Logs

Every MCP-relevant event is emitted as a `LogEntry` with `kind = "mcp"` via `state.emit_log`. They flow through the existing SSE log stream (`GET /v1/logs`) and are visible on the dashboard:

- `loading MCP config…`
- `filesystem ready (2 tools)`
- `MCP ready (2 tools)`
- `tools available: filesystem.read_file, filesystem.list_directory`
- `tool call (0): filesystem.list_directory({"path":"/tmp"})`
- `tool result (842 bytes)`
- `tool error: …`

That single audit trail is the "auditable protocol" promise of this feature.

## Try It

1. `npx -y @modelcontextprotocol/server-filesystem /tmp` should run (Node + npm available).
2. `ollama pull qwen3:8b` (or any tool-capable model).
3. `bun run tauri dev`. On first launch, watch the dashboard for `mcp` lines confirming the filesystem server connected.
4. Connect a Telegram bot, then send: *"List the files in /tmp."*
5. Expect a `tool call` and `tool result` line in the log, followed by a coherent reply on Telegram.

## Future Work

Permission prompts, multiple servers in the default config, a frontend tools panel, hot reload of `mcp.json`, HTTP/SSE transport, resources & prompts. Not in this PR.
80 changes: 73 additions & 7 deletions e2e/setup-dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,83 @@ async function mockApis(page: import("@playwright/test").Page) {
await route.continue();
}
});

await page.route(`${PENGINE_API_BASE}/v1/ollama/models`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
reachable: true,
active_model: "qwen3-coder:30b",
selected_model: null,
models: ["qwen3-coder:30b"],
}),
});
});

await page.route(`${PENGINE_API_BASE}/v1/ollama/model`, async (route) => {
if (route.request().method() === "PUT") {
let selected_model: string | null = null;
const raw = route.request().postData();
if (raw) {
try {
const body = JSON.parse(raw) as { model?: string | null };
let m: string | null = null;
if (typeof body.model === "string") m = body.model.trim();
selected_model = m && m.length > 0 ? m : null;
} catch {
/* ignore malformed body */
}
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ selected_model }),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
await route.continue();
}
});

await page.route(
(url) => url.href.startsWith(`${PENGINE_API_BASE}/v1/mcp/servers`),
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ servers: {} }),
});
},
);

await page.route(
(url) => url.href.startsWith(`${PENGINE_API_BASE}/v1/mcp/tools`),
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
},
);
}

test.describe("setup to dashboard flow", () => {
test("shows 'no device' on dashboard when disconnected", async ({ page }) => {
// Force offline so the assertion does not depend on a local Pengine/Ollama install.
await page.route(`${PENGINE_API_BASE}/v1/health`, async (route) => {
await route.abort("failed");
});
await page.route(`${PENGINE_API_BASE}/v1/ollama/models`, async (route) => {
await route.abort("failed");
});

await page.goto("/dashboard");
await expect(page.getByTestId("app-ready")).toBeVisible();

await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText("No device connected")).toBeVisible();
await expect(page.getByRole("link", { name: "Go to setup" })).toBeVisible();
await expect(page.getByText("Some services offline")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("link", { name: "Setup", exact: true })).toBeVisible();
});

test("walks all setup wizard steps and opens dashboard", async ({ page }) => {
Expand Down Expand Up @@ -128,10 +195,8 @@ test.describe("setup to dashboard flow", () => {
await page.getByRole("button", { name: "Open dashboard" }).click();

await expect(page).toHaveURL(/\/dashboard$/);
await expect(
page.getByRole("heading", { name: "Connected device and running services" }),
).toBeVisible();
await expect(page.getByText("Telegram gateway")).toBeVisible();
await expect(page.getByText("All systems running")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("@TestPengineBot")).toBeVisible();
});

test("loads dashboard when device is already connected", async ({ page }) => {
Expand All @@ -144,6 +209,7 @@ test.describe("setup to dashboard flow", () => {
await expect(page.getByTestId("app-ready")).toBeVisible();

await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText("1 connected device")).toBeVisible();
await expect(page.getByText("All systems running")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "Disconnect" })).toBeVisible();
});
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
]
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-menubar": "^1.1.16",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-opener": "^2",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
Expand Down
70 changes: 70 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading