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

`choosekit` is an independent project with no affiliation to TypeSafe or Jev.

## 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. Every `choose` call uses this configuration.

## llama.cpp

```ts
Expand Down Expand Up @@ -50,10 +54,6 @@ The llama.cpp backend requires its native `/tokenize` and `/completion` endpoint

The library has no telemetry.

### MCP server

[`choosekit-mcp`](packages/choosekit-mcp/README.md) exposes the same local llama.cpp decision interface as a read-only stdio tool for Claude Code, Codex, and OpenCode. Configure the llama.cpp endpoint, model, and scoring mode with environment variables when starting the MCP server. Every `choose` call uses this configuration.

## OpenRouter

```ts
Expand Down
42 changes: 26 additions & 16 deletions packages/choosekit-mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,32 @@
# choosekit-mcp

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

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

## Configuration

Set the llama.cpp connection when the MCP process starts:
Select and configure the backend when the MCP process starts. `CHOOSEKIT_BACKEND` defaults to `llama-cpp`.

| Environment variable | Default | Description |
|---|---|---|
| `CHOOSEKIT_BASE_URL` | Required | Base URL of the llama.cpp server, for example `http://127.0.0.1:8080` |
| `CHOOSEKIT_MODEL` | Not set | Model alias sent with llama.cpp requests |
| `CHOOSEKIT_MODE` | `labels` | Candidate scoring mode: `labels` or `minimal-prefix` |
| 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 |
| llama.cpp | `CHOOSEKIT_BASE_URL` | Required | Base URL of the llama.cpp server, for example `http://127.0.0.1:8080` |
| 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. The llama.cpp server must provide its native `/tokenize` and `/completion` endpoints.
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.

### Claude Code

```sh
claude mcp add choosekit --env CHOOSEKIT_BASE_URL=http://127.0.0.1:8080 -- npx -y choosekit-mcp
```

On native Windows, launch `npx` through `cmd`:

```powershell
claude mcp add choosekit --env CHOOSEKIT_BASE_URL=http://127.0.0.1:8080 -- cmd /c npx -y choosekit-mcp
```

### Codex

```sh
Expand All @@ -40,6 +39,16 @@ 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
```

### 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
```

On native Windows, launch `npx` through `cmd` in any example above: replace `-- npx -y choosekit-mcp` with `-- cmd /c npx -y choosekit-mcp`.

## Tool

The server exposes one tool named `choose`:
Expand All @@ -58,11 +67,12 @@ The server exposes one tool named `choose`:

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. Probabilities represent relative preference among the supplied choices. Estimating correctness requires separate calibration.
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.

## Requirements

- Node.js 20 or newer.
- A reachable llama.cpp server with a compatible model already loaded.
- For llama.cpp, a reachable server with a compatible model already loaded.
- 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.

4 changes: 2 additions & 2 deletions packages/choosekit-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "choosekit-mcp",
"version": "0.1.0",
"version": "0.2.0",
"description": "Use choosekit as a local MCP server.",
"license": "Apache-2.0",
"author": {
Expand Down Expand Up @@ -37,7 +37,7 @@
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"choosekit": "^0.4.2",
"choosekit": "^0.5.0",
"zod": "^4.6.4"
},
"devDependencies": {
Expand Down
25 changes: 23 additions & 2 deletions packages/choosekit-mcp/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
#!/usr/bin/env node

import { serveStdio } from "@modelcontextprotocol/server/stdio";
import type { Chooser } from "choosekit";
import { fromLlamaCpp } from "choosekit/llama-cpp";
import { fromOpenRouter } from "choosekit/openrouter";
import { loadConfig } from "./config.js";
import { buildServer } from "./server.js";

try {
const config = loadConfig();
const chooser = fromLlamaCpp(config);
serveStdio(() => buildServer(chooser, { mode: config.mode }));
let chooser: Chooser;
switch (config.backend) {
case "llama-cpp":
chooser = fromLlamaCpp({
baseURL: config.baseURL,
...(config.model === undefined ? {} : { model: config.model }),
mode: config.mode,
});
break;
case "openrouter":
chooser = fromOpenRouter({
apiKey: config.apiKey,
model: config.model,
...(config.provider === undefined ? {} : { provider: config.provider }),
});
break;
}
serveStdio(() => buildServer(chooser, {
backend: config.backend,
mode: config.mode,
}));
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown configuration error.";
console.error(`choosekit-mcp: ${message}`);
Expand Down
78 changes: 59 additions & 19 deletions packages/choosekit-mcp/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,69 @@
import { z } from "zod";

const configSchema = z.object({
CHOOSEKIT_BASE_URL: z.string().trim().url(),
CHOOSEKIT_MODEL: z.string().trim().min(1).optional(),
CHOOSEKIT_MODE: z.enum(["labels", "minimal-prefix"]).default("labels"),
});
const urlSchema = z.string().url();

export interface Config {
baseURL: string;
model?: string;
mode: "labels" | "minimal-prefix";
export type Config =
| {
readonly backend: "llama-cpp";
readonly baseURL: string;
readonly model?: string;
readonly mode: "labels" | "minimal-prefix";
}
| {
readonly backend: "openrouter";
readonly apiKey: string;
readonly model: string;
readonly provider?: string;
readonly mode: "labels";
};

function requiredText(value: string | undefined, name: string): string {
const text = value?.trim();
if (!text) throw new TypeError(`${name} is required.`);
return text;
}

function optionalText(value: string | undefined, name: string): string | undefined {
if (value === undefined) return undefined;
const text = value.trim();
if (!text) throw new TypeError(`${name} must not be empty.`);
return text;
}

export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const parsed = configSchema.parse({
CHOOSEKIT_BASE_URL: env.CHOOSEKIT_BASE_URL,
CHOOSEKIT_MODEL: env.CHOOSEKIT_MODEL,
CHOOSEKIT_MODE: env.CHOOSEKIT_MODE,
});
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.");
}

if (backend === "openrouter") {
const mode = env.CHOOSEKIT_MODE?.trim() ?? "labels";
if (mode !== "labels") {
throw new TypeError("CHOOSEKIT_MODE must be labels when using OpenRouter.");
}
const provider = optionalText(env.OPENROUTER_PROVIDER, "OPENROUTER_PROVIDER");
return {
backend,
apiKey: requiredText(env.OPENROUTER_API_KEY, "OPENROUTER_API_KEY"),
model: requiredText(env.CHOOSEKIT_MODEL, "CHOOSEKIT_MODEL"),
...(provider === undefined ? {} : { provider }),
mode,
};
}

const baseURL = requiredText(env.CHOOSEKIT_BASE_URL, "CHOOSEKIT_BASE_URL");
if (!urlSchema.safeParse(baseURL).success) {
throw new TypeError("CHOOSEKIT_BASE_URL must be a valid URL.");
}
const model = optionalText(env.CHOOSEKIT_MODEL, "CHOOSEKIT_MODEL");
const mode = env.CHOOSEKIT_MODE?.trim() ?? "labels";
if (mode !== "labels" && mode !== "minimal-prefix") {
throw new TypeError("CHOOSEKIT_MODE must be labels or minimal-prefix.");
}
return {
baseURL: parsed.CHOOSEKIT_BASE_URL,
...(parsed.CHOOSEKIT_MODEL === undefined
? {}
: { model: parsed.CHOOSEKIT_MODEL }),
mode: parsed.CHOOSEKIT_MODE,
backend,
baseURL,
...(model === undefined ? {} : { model }),
mode,
};
}
29 changes: 21 additions & 8 deletions packages/choosekit-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from "zod";
export type ChoiceMode = "labels" | "minimal-prefix";

export interface ServerOptions {
readonly backend?: "llama-cpp" | "openrouter";
readonly mode?: ChoiceMode;
}

Expand All @@ -21,14 +22,14 @@ const usageSchema = z.object({
const decisionSchema = z.object({
choice: z.string(),
distribution: z.record(z.string(), z.number().finite().min(0).max(1)),
scores: z.record(z.string(), z.number().finite().max(0)),
scores: z.record(z.string(), z.union([z.number().finite().max(0), z.null()])),
margin: z.number().finite().min(0).max(1),
entropy: z.number().finite().nonnegative(),
boundaryTokens: z.number().int().nonnegative(),
usage: usageSchema.optional(),
}).strict();

function inputSchema(mode: ChoiceMode) {
function inputSchema(backend: "llama-cpp" | "openrouter", mode: ChoiceMode) {
const choicesSchema = z.fromJSONSchema({
type: "object",
propertyNames: {
Expand All @@ -40,7 +41,9 @@ function inputSchema(mode: ChoiceMode) {
pattern: "\\S",
},
minProperties: 2,
...(mode === "labels" ? { maxProperties: 26 } : {}),
...(backend === "openrouter"
? { maxProperties: 20 }
: mode === "labels" ? { maxProperties: 26 } : {}),
}) as z.ZodType<Record<string, string>>;

return z.object({
Expand All @@ -65,7 +68,7 @@ function errorResult(error: unknown, signal: AbortSignal) {
if (error instanceof ScoringError) {
return {
isError: true as const,
content: [{ type: "text" as const, text: "llama.cpp could not score the supplied choices." }],
content: [{ type: "text" as const, text: "The model could not score the supplied choices." }],
};
}
return {
Expand All @@ -74,8 +77,18 @@ function errorResult(error: unknown, signal: AbortSignal) {
};
}

function wireScores(scores: Readonly<Record<string, number>>):
Readonly<Record<string, number | null>> {
return Object.fromEntries(Object.entries(scores).map(([key, score]) =>
[key, score === -Infinity ? null : score]));
}

export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpServer {
if (typeof chooser !== "function") throw new TypeError("chooser must be a function.");
const backend = options.backend ?? "llama-cpp";
if (backend !== "llama-cpp" && backend !== "openrouter") {
throw new TypeError("backend must be llama-cpp or openrouter.");
}
const mode = options.mode ?? "labels";
if (mode !== "labels" && mode !== "minimal-prefix") {
throw new TypeError("mode must be labels or minimal-prefix.");
Expand All @@ -86,11 +99,11 @@ export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpS
"choose",
{
description: toolDescription(mode),
inputSchema: inputSchema(mode),
inputSchema: inputSchema(backend, mode),
outputSchema: decisionSchema,
annotations: {
readOnlyHint: true,
openWorldHint: false,
openWorldHint: backend === "openrouter",
},
},
async ({ context, question, choices }, ctx) => {
Expand All @@ -104,15 +117,15 @@ export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpS
const structuredContent: {
choice: string;
distribution: Readonly<Record<string, number>>;
scores: Readonly<Record<string, number>>;
scores: Readonly<Record<string, number | null>>;
margin: number;
entropy: number;
boundaryTokens: number;
usage?: Usage;
} = {
choice: decision.choice,
distribution: decision.distribution,
scores: decision.scores,
scores: wireScores(decision.scores),
margin: decision.margin,
entropy: decision.entropy,
boundaryTokens: decision.boundaryTokens,
Expand Down
Loading
Loading