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
902 changes: 902 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
"typescript-eslint": "^8.18.2"
},
"dependencies": {
"@langchain/anthropic": "^1.3.26",
"@langchain/openai": "^1.4.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"commander": "^14.0.3",
"mcp-use": "^1.22.3",
"croner": "^10.0.1",
"nanoid": "^5.1.7",
"pkce-challenge": "^5.0.0",
Expand Down
46 changes: 46 additions & 0 deletions skills/llm/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
name: llm
description: Run an async agentic task using the same tools available in this session. Use this to delegate long-running or multi-step work that should happen in the background — e.g. "run a full cycling progress update", "research and summarise X", "execute this plan while I do something else".
---

# LLM Skill

Spins up a second agent connected to this MCP server. The sub-agent has access to all the same tools (`shell`, `memory_read/write`, `patch_file`, etc.) and the same system instructions. Use it to delegate async work — fire it off in the background and let it run independently.

## Usage

```bash
bun /app/skills/llm/scripts/llm.ts "<prompt>"
```

For async / background execution:

```bash
shell(background: true, command: 'bun /app/skills/llm/scripts/llm.ts "your prompt here"')
```

## Options

| Flag | Default | Description |
|---|---|---|
| `-p, --provider <name>` | `anthropic` | LLM provider (`anthropic`, `openai`) |
| `-m, --model <id>` | `claude-sonnet-4-6` | Model ID |

```bash
bun /app/skills/llm/scripts/llm.ts --model claude-haiku-4-5 "summarise the TODO list"
bun /app/skills/llm/scripts/llm.ts --provider openai --model gpt-4o "..."
```

## How it works

- Connects to the MCP server at `http://127.0.0.1:<PORT>/mcp` (localhost — no auth required)
- Uses the same `INSTRUCTIONS.md` as system prompt
- Runs a full agentic loop via `mcp-use` + LangChain until the task is complete
- `PORT` defaults to `3000`; override with the `PORT` env var if needed

## Gotchas

- The MCP server must be running before invoking this skill (it always is in normal operation)
- Run with `background: true` for long tasks so the current session isn't blocked
- The sub-agent shares the same `/data/MEMORY.md` and filesystem — changes it makes are real
- stdout = final answer; stderr = tool call progress (only visible if not run in background)
81 changes: 81 additions & 0 deletions skills/llm/scripts/llm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bun
// Agentic CLI — connects to the local MCP server and uses its tools.
// Usage: bun bin/llm.ts [--provider <provider>] [--model <model>] "<prompt>"
// echo "<prompt>" | bun bin/llm.ts

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Command } from "commander";
process.env["MCP_USE_ANONYMIZED_TELEMETRY"] = "false";
import { MCPAgent, MCPClient } from "mcp-use";

const program = new Command()
.name("llm")
.description("Agentic CLI — connects to the Backoffice MCP server using its tools")
.argument("[prompt...]", "Prompt to send (reads from stdin if omitted)")
.option("-p, --provider <provider>", "LLM provider (anthropic, openai)", "anthropic")
.option("-m, --model <model>", "Model ID (e.g. claude-sonnet-4-6, gpt-4o)", "claude-sonnet-4-6")
.parse(process.argv);

const opts = program.opts<{ provider: string; model: string }>();

async function resolvePrompt(): Promise<string> {
if (program.args.length > 0) return program.args.join(" ");
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
const text = Buffer.concat(chunks).toString("utf8").trim();
if (text === "") program.help();
return text;
}

const API_KEY_ENV: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
};

async function createLLM(provider: string, model: string) {
const envVar = API_KEY_ENV[provider];
if (envVar !== undefined && !process.env[envVar]) {
console.error(`Error: ${envVar} is not set. Export it and try again.`);
process.exit(1);
}
switch (provider) {
case "anthropic": {
const { ChatAnthropic } = await import("@langchain/anthropic");
return new ChatAnthropic({ model });
}
case "openai": {
const { ChatOpenAI } = await import("@langchain/openai");
return new ChatOpenAI({ model });
}
default:
throw new Error(`Unsupported provider: ${provider}. Supported: anthropic, openai`);
}
}

async function main() {
const prompt = await resolvePrompt();
const port = process.env["PORT"] ?? "3000";

const client = MCPClient.fromDict({
mcpServers: {
backoffice: { url: `http://127.0.0.1:${port}/mcp` },
},
});

const systemPrompt = readFileSync(
join(import.meta.dir, "../../../src/INSTRUCTIONS.md"),
"utf8",
).trim();

const llm = await createLLM(opts.provider, opts.model);
const agent = new MCPAgent({ llm, client, systemPrompt, maxSteps: 50 });

const result = await agent.run({ prompt });
console.log(result);
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ interface SessionEntry {

const oauthSessions = new Map<string, SessionEntry>();
const tokenSessions = new Map<string, SessionEntry>();
const localSessions = new Map<string, SessionEntry>();

function bodyHasInitialize(body: unknown): boolean {
if (body === undefined || body === null) {
Expand Down Expand Up @@ -213,7 +214,7 @@ startRpcServer();
Bun.serve({
port: listenPort,
hostname: "0.0.0.0",
async fetch(req) {
async fetch(req, server) {
const url = new URL(req.url);

if (url.pathname === "/version") {
Expand All @@ -231,6 +232,11 @@ Bun.serve({
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: mcpCorsHeaders });
}
const clientIp = server.requestIP(req)?.address ?? "";
const isLocal = clientIp === "127.0.0.1" || clientIp === "::1" || clientIp === "::ffff:127.0.0.1";
if (isLocal) {
return handleMcpSession(req, localSessions);
}
if (USE_MCP_TOKEN_AUTH) {
return handleMcpWithStaticToken(req);
}
Expand Down
29 changes: 4 additions & 25 deletions src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,26 @@
* that the main Bun server then proxies.
*
* Security:
* - A shared secret is generated at startup and written to SECRET_PATH.
* Every RPC call must include params.secret matching this value.
* - The Unix socket itself is the trust boundary — only local processes can reach it.
* - Route targets must be http://localhost:<port> or http://127.0.0.1:<port>.
* - Route patterns must start with an allowed prefix (ALLOWED_PATTERNS).
*
* Supported methods:
* route.register { pattern: string, target: string, secret: string }
* route.unregister { pattern: string, secret: string }
* route.register { pattern: string, target: string }
* route.unregister { pattern: string }
*/

import { createServer } from "node:net";
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { existsSync, unlinkSync } from "node:fs";

export const SOCKET_PATH = "/tmp/backoffice.sock";
export const SECRET_PATH = "/tmp/backoffice-rpc.secret";

const ALLOWED_PATTERNS = ["/share"];
const LOCALHOST_TARGET = /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/;

/** pattern (e.g. "/share") → target base URL (e.g. "http://localhost:3001") */
export const routeRegistry = new Map<string, string>();

let rpcSecret = "";

function verifySecret(presented: string | undefined): boolean {
if (!presented || !rpcSecret) return false;
const a = Buffer.from(presented, "utf8");
const b = Buffer.from(rpcSecret, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

function isAllowedPattern(pattern: string): boolean {
return ALLOWED_PATTERNS.some((p) => pattern === p || pattern.startsWith(p + "/"));
}
Expand All @@ -50,10 +37,6 @@ function isAllowedTarget(target: string): boolean {
export function startRpcServer(): void {
if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);

rpcSecret = randomBytes(32).toString("hex");
writeFileSync(SECRET_PATH, rpcSecret + "\n", { mode: 0o600 });
console.log(`[rpc] Secret written to ${SECRET_PATH}`);

const server = createServer((socket) => {
let buf = "";

Expand Down Expand Up @@ -97,10 +80,6 @@ function handleRpc(msg: unknown): object {
const id = req.id ?? null;
const params: RpcParams = req.params ?? {};

if (!verifySecret(params["secret"])) {
return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid or missing secret" }, id };
}

if (req.method === "route.register") {
const pattern = params["pattern"];
const target = params["target"];
Expand Down
6 changes: 6 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export function create(server: McpServer): void {

const LOG_FILE = path.join("/data", "log.jsonl");

function ensureLogFile(): void {
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
if (!fs.existsSync(LOG_FILE)) fs.writeFileSync(LOG_FILE, "");
}

interface ToolCallContext {
toolName: string;
args: Record<string, unknown>;
Expand Down Expand Up @@ -58,6 +63,7 @@ function applyLogging(server: McpServer): void {

const callId = nanoid();

ensureLogFile();
fs.appendFileSync(
LOG_FILE,
JSON.stringify({
Expand Down
Loading