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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The project was inspired by [Jev and the System One model interface](https://typ

## MCP server

[`choosekit-mcp`](packages/choosekit-mcp/README.md) exposes choosekit through llama.cpp or OpenRouter as a read-only stdio tool for Claude Code, Codex, and OpenCode. Select the backend and configure it with environment variables when starting the MCP server.
[`choosekit-mcp`](packages/choosekit-mcp/README.md) exposes choosekit through llama.cpp, Ollama, or OpenRouter as a read-only stdio tool for Claude Code, Codex, and OpenCode. Select the backend and configure it with environment variables when starting the MCP server.

## llama.cpp

Expand Down Expand Up @@ -98,7 +98,7 @@ import { fromOpenRouter } from "choosekit/openrouter";

const choose = fromOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY!,
model: "qwen/qwen3.8-27b",
model: "your-model",
});
```

Expand Down Expand Up @@ -131,6 +131,8 @@ const decision = await choose({

Supported media types are PNG, JPEG, and WebP. llama.cpp image inputs currently support `labels` mode only.

The MCP server accepts image file paths through `imagePaths` in `labels` mode. Paths are resolved from the server's working directory by default; set `CHOOSEKIT_IMAGE_ROOT` to use another root. Every image must remain within that root and be a PNG, JPEG, or WebP file. With OpenRouter, the image contents are sent to the remote service.

## Scoring modes

| Mode | Candidate representation | Use when |
Expand Down
39 changes: 33 additions & 6 deletions packages/choosekit-mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# choosekit-mcp

`choosekit-mcp` exposes [choosekit](https://github.com/NotXf1le/choosekit) as a read-only MCP tool backed by llama.cpp or OpenRouter.
`choosekit-mcp` exposes [choosekit](https://github.com/NotXf1le/choosekit) as a read-only MCP tool backed by llama.cpp, Ollama, or OpenRouter.

The server uses stdio. It returns a decision and probability distribution. Execution of the selected action remains with the MCP client.

Expand All @@ -10,16 +10,18 @@ Select and configure the backend when the MCP process starts. `CHOOSEKIT_BACKEND

| Backend | Environment variable | Default | Description |
|---|---|---|---|
| Both | `CHOOSEKIT_BACKEND` | `llama-cpp` | `llama-cpp` or `openrouter` |
| Both | `CHOOSEKIT_MODEL` | Not set | Optional for llama.cpp; required for OpenRouter |
| Both | `CHOOSEKIT_MODE` | `labels` | `labels` for either backend; `minimal-prefix` is llama.cpp only |
| All | `CHOOSEKIT_BACKEND` | `llama-cpp` | `llama-cpp`, `ollama`, or `openrouter` |
| All | `CHOOSEKIT_MODEL` | Not set | Optional for llama.cpp; required for Ollama and OpenRouter |
| All | `CHOOSEKIT_MODE` | `labels` | `labels` for every backend; `minimal-prefix` is llama.cpp only |
| All | `CHOOSEKIT_IMAGE_ROOT` | Current working directory | Root directory for files supplied through `imagePaths` |
| llama.cpp | `CHOOSEKIT_BASE_URL` | Required | Base URL of the llama.cpp server, for example `http://127.0.0.1:8080` |
| Ollama | `CHOOSEKIT_BASE_URL` | `http://127.0.0.1:11434` | Base URL of the Ollama server |
| OpenRouter | `OPENROUTER_API_KEY` | Required | OpenRouter API key |
| OpenRouter | `OPENROUTER_PROVIDER` | Not set | Pins one provider and disables fallback |

Every tool call uses the configuration set when the MCP process starts. Backend settings are not accepted as tool arguments. The llama.cpp server must provide its native `/tokenize` and `/completion` endpoints. OpenRouter receives the context, question, and choice descriptions.

OpenRouter supports `labels` mode with up to 20 choices. llama.cpp supports up to 26 choices in `labels` mode and has no additional MCP choice limit in `minimal-prefix` mode.
Ollama and OpenRouter support `labels` mode with up to 20 choices. llama.cpp supports up to 26 choices in `labels` mode and has no additional MCP choice limit in `minimal-prefix` mode.

### Claude Code

Expand All @@ -39,12 +41,20 @@ codex mcp add choosekit --env CHOOSEKIT_BASE_URL=http://127.0.0.1:8080 -- npx -y
opencode mcp add choosekit --env CHOOSEKIT_BASE_URL=http://127.0.0.1:8080 -- npx -y choosekit-mcp
```

### Ollama

For example, with Codex:

```sh
codex mcp add choosekit --env CHOOSEKIT_BACKEND=ollama --env CHOOSEKIT_MODEL=your-model -- npx -y choosekit-mcp
```

### OpenRouter

For example, with Codex:

```sh
codex mcp add choosekit --env CHOOSEKIT_BACKEND=openrouter --env OPENROUTER_API_KEY=... --env CHOOSEKIT_MODEL=... -- npx -y choosekit-mcp
codex mcp add choosekit --env CHOOSEKIT_BACKEND=openrouter --env OPENROUTER_API_KEY=... --env CHOOSEKIT_MODEL=your-model -- npx -y choosekit-mcp
```

On native Windows, launch `npx` through `cmd` in any example above: replace `-- npx -y choosekit-mcp` with `-- cmd /c npx -y choosekit-mcp`.
Expand All @@ -65,6 +75,22 @@ The server exposes one tool named `choose`:
}
```

To score images, add `imagePaths` in `labels` mode:

```json
{
"context": "Inspect the attached screenshot.",
"question": "Which state is the interface in?",
"choices": {
"ready": "The interface is ready for input.",
"loading": "The interface is still loading."
},
"imagePaths": ["screenshots/status.png"]
}
```

Relative paths are resolved from `CHOOSEKIT_IMAGE_ROOT`, which defaults to the MCP process working directory. Every image path must remain within that root. PNG, JPEG, and WebP files are supported, and the selected model must support vision. With OpenRouter, the image contents are sent to the remote service.

With `labels`, choosekit maps the supplied choice keys to A/B/C labels for scoring, so each description must contain the option's full meaning. With `minimal-prefix`, it scores the shortest token prefixes that distinguish the original keys. When no supplied option may apply, add an explicit choice such as `insufficient_information`.

The result contains the selected key, the complete normalized distribution, raw scores, margin, entropy, token-boundary rollback, and backend usage when available. A score is `null` when the upstream backend did not return a log probability for that choice; its probability in the distribution is `0`. Probabilities represent relative preference among the supplied choices. Estimating correctness requires separate calibration.
Expand All @@ -73,6 +99,7 @@ The result contains the selected key, the complete normalized distribution, raw

- Node.js 20 or newer.
- For llama.cpp, a reachable server with a compatible model already loaded.
- For Ollama, version 0.12.11 or newer and a compatible model.
- For OpenRouter, an API key and a model that returns the required log probabilities.

[Apache-2.0](LICENSE). Copyright 2026 NotXf1le.
12 changes: 6 additions & 6 deletions packages/choosekit-mcp/package-lock.json

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

7 changes: 4 additions & 3 deletions packages/choosekit-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "choosekit-mcp",
"version": "0.2.0",
"description": "Use choosekit as a local MCP server.",
"version": "0.3.0",
"description": "Use choosekit as an MCP server.",
"license": "Apache-2.0",
"author": {
"name": "NotXf1le",
Expand Down Expand Up @@ -37,7 +37,7 @@
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"choosekit": "^0.5.0",
"choosekit": "^0.6.0",
"zod": "^4.6.4"
},
"devDependencies": {
Expand All @@ -51,6 +51,7 @@
"mcp",
"llm",
"llama.cpp",
"ollama",
"choosekit"
]
}
9 changes: 9 additions & 0 deletions packages/choosekit-mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import type { Chooser } from "choosekit";
import { fromLlamaCpp } from "choosekit/llama-cpp";
import { fromOllama } from "choosekit/ollama";
import { fromOpenRouter } from "choosekit/openrouter";
import { loadConfig } from "./config.js";
import { createImageLoader } from "./images.js";
import { buildServer } from "./server.js";

try {
Expand All @@ -18,6 +20,12 @@ try {
mode: config.mode,
});
break;
case "ollama":
chooser = fromOllama({
model: config.model,
...(config.baseURL === undefined ? {} : { baseURL: config.baseURL }),
});
break;
case "openrouter":
chooser = fromOpenRouter({
apiKey: config.apiKey,
Expand All @@ -28,6 +36,7 @@ try {
}
serveStdio(() => buildServer(chooser, {
backend: config.backend,
imageLoader: createImageLoader(config.imageRoot),
mode: config.mode,
}));
} catch (error) {
Expand Down
34 changes: 32 additions & 2 deletions packages/choosekit-mcp/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,24 @@ export type Config =
| {
readonly backend: "llama-cpp";
readonly baseURL: string;
readonly imageRoot?: string;
readonly model?: string;
readonly mode: "labels" | "minimal-prefix";
}
| {
readonly backend: "openrouter";
readonly apiKey: string;
readonly imageRoot?: string;
readonly model: string;
readonly provider?: string;
readonly mode: "labels";
}
| {
readonly backend: "ollama";
readonly baseURL?: string;
readonly imageRoot?: string;
readonly model: string;
readonly mode: "labels";
};

function requiredText(value: string | undefined, name: string): string {
Expand All @@ -32,8 +41,27 @@ function optionalText(value: string | undefined, name: string): string | undefin

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const backend = env.CHOOSEKIT_BACKEND?.trim() ?? "llama-cpp";
if (backend !== "llama-cpp" && backend !== "openrouter") {
throw new TypeError("CHOOSEKIT_BACKEND must be llama-cpp or openrouter.");
const imageRoot = optionalText(env.CHOOSEKIT_IMAGE_ROOT, "CHOOSEKIT_IMAGE_ROOT");
if (backend !== "llama-cpp" && backend !== "ollama" && backend !== "openrouter") {
throw new TypeError("CHOOSEKIT_BACKEND must be llama-cpp, ollama, or openrouter.");
}

if (backend === "ollama") {
const mode = env.CHOOSEKIT_MODE?.trim() ?? "labels";
if (mode !== "labels") {
throw new TypeError("CHOOSEKIT_MODE must be labels when using Ollama.");
}
const baseURL = optionalText(env.CHOOSEKIT_BASE_URL, "CHOOSEKIT_BASE_URL");
if (baseURL !== undefined && !urlSchema.safeParse(baseURL).success) {
throw new TypeError("CHOOSEKIT_BASE_URL must be a valid URL.");
}
return {
backend,
...(baseURL === undefined ? {} : { baseURL }),
...(imageRoot === undefined ? {} : { imageRoot }),
model: requiredText(env.CHOOSEKIT_MODEL, "CHOOSEKIT_MODEL"),
mode,
};
}

if (backend === "openrouter") {
Expand All @@ -45,6 +73,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
return {
backend,
apiKey: requiredText(env.OPENROUTER_API_KEY, "OPENROUTER_API_KEY"),
...(imageRoot === undefined ? {} : { imageRoot }),
model: requiredText(env.CHOOSEKIT_MODEL, "CHOOSEKIT_MODEL"),
...(provider === undefined ? {} : { provider }),
mode,
Expand All @@ -63,6 +92,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
return {
backend,
baseURL,
...(imageRoot === undefined ? {} : { imageRoot }),
...(model === undefined ? {} : { model }),
mode,
};
Expand Down
74 changes: 74 additions & 0 deletions packages/choosekit-mcp/src/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ImageInput, ImageMediaType } from "choosekit";
import { readFile, realpath, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";

export type ImageLoader = (
paths: readonly string[],
signal?: AbortSignal,
) => Promise<readonly ImageInput[]>;

export class ImageLoadError extends Error {
override readonly name = "ImageLoadError";
}

function isInside(root: string, path: string): boolean {
const fromRoot = relative(root, path);
return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
}

function mediaType(data: Uint8Array): ImageMediaType | undefined {
if (data.length >= 8
&& data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
&& data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
return "image/png";
}
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
return "image/jpeg";
}
if (data.length >= 12
&& data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46
&& data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) {
return "image/webp";
}
return undefined;
}

export function createImageLoader(root = process.cwd()): ImageLoader {
let resolvedRoot: Promise<string> | undefined;
return async (paths, signal) => {
try {
resolvedRoot ??= realpath(resolve(root)).then(async (path) => {
if (!(await stat(path)).isDirectory()) {
throw new ImageLoadError("image root must be a directory.");
}
return path;
});
const rootPath = await resolvedRoot;
const images: ImageInput[] = [];
for (const input of paths) {
signal?.throwIfAborted();
if (typeof input !== "string" || input.trim().length === 0) {
throw new ImageLoadError("image paths must be non-empty strings.");
}
const path = await realpath(resolve(rootPath, input));
if (!isInside(rootPath, path)) {
throw new ImageLoadError("image path is outside the configured root.");
}
if (!(await stat(path)).isFile()) {
throw new ImageLoadError("image path must identify a regular file.");
}
const data = await readFile(path, signal ? { signal } : undefined);
const detected = mediaType(data);
if (detected === undefined) {
throw new ImageLoadError("image must be PNG, JPEG, or WebP.");
}
images.push(Object.freeze({ mediaType: detected, base64: data.toString("base64") }));
}
return Object.freeze(images);
} catch (error) {
if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw error;
if (error instanceof ImageLoadError) throw error;
throw new ImageLoadError("image file could not be read.", { cause: error });
}
};
}
Loading
Loading