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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- Added the MCP `chooseMany` tool for independent named questions with shared context and optional images.

## 0.8.0 - 2026-10-10

- Added `chooseMany()` for independent questions with common context and optional images.
Expand Down
2 changes: 1 addition & 1 deletion 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, 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.
[`choosekit-mcp`](packages/choosekit-mcp/README.md) provides read-only `choose` and `chooseMany` MCP tools through llama.cpp, Ollama, or OpenRouter for Claude Code, Codex, and OpenCode. Select the backend and configure it with environment variables when starting the MCP server.

## llama.cpp

Expand Down
40 changes: 36 additions & 4 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, Ollama, or OpenRouter.
`choosekit-mcp` exposes [choosekit](https://github.com/NotXf1le/choosekit) through read-only MCP tools 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 Down Expand Up @@ -60,9 +60,11 @@ codex mcp add choosekit --env CHOOSEKIT_BACKEND=openrouter --env OPENROUTER_API_

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

## Tool
## Tools

The server exposes one tool named `choose`:
### choose

Score a question against the supplied choices:

```json
{
Expand All @@ -76,7 +78,37 @@ The server exposes one tool named `choose`:
}
```

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

Ask independent named questions about the same context:

```json
{
"context": "A payout failed three days in a row. Payroll is due today.",
"questions": {
"route": {
"question": "Which team should handle this?",
"choices": {
"billing": "Payments, payouts, invoices, and refunds",
"technical": "Bugs, outages, integrations, and API errors"
}
},
"urgency": {
"question": "How urgent is this?",
"choices": {
"low": "The request can wait",
"high": "The request needs immediate attention"
}
}
}
}
```

The result maps each question name to its decision, including separate token counts when available. Choice limits apply to each question.

### Images

To score images, add `imagePaths` in `labels` mode. Both tools accept this field alongside `context`; `chooseMany` shares the images across all questions.

```json
{
Expand Down
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.4.0",
"version": "0.5.0",
"description": "Use choosekit as an MCP server.",
"license": "Apache-2.0",
"author": {
Expand Down Expand Up @@ -37,7 +37,7 @@
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"choosekit": "^0.7.0",
"choosekit": "^0.8.0",
"zod": "^4.6.4"
},
"devDependencies": {
Expand Down
89 changes: 65 additions & 24 deletions packages/choosekit-mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { McpServer } from "@modelcontextprotocol/server";
import { ScoringError, type Chooser, type Decision, type Usage } from "choosekit";
import { chooseMany, ScoringError, type Chooser, type Decision } from "choosekit";
import { createRequire } from "node:module";
import { z } from "zod";
import { ImageLoadError, type ImageLoader } from "./images.js";
Expand Down Expand Up @@ -31,7 +31,7 @@ const decisionSchema = z.object({
usage: usageSchema.optional(),
}).strict();

function inputSchema(backend: "llama-cpp" | "ollama" | "openrouter", mode: ChoiceMode,
function inputSchemas(backend: "llama-cpp" | "ollama" | "openrouter", mode: ChoiceMode,
imagesEnabled: boolean) {
const choicesSchema = z.fromJSONSchema({
type: "object",
Expand All @@ -49,17 +49,28 @@ function inputSchema(backend: "llama-cpp" | "ollama" | "openrouter", mode: Choic
: mode === "labels" ? { maxProperties: 26 } : {}),
}) as z.ZodType<Record<string, string>>;

return z.object({
context: z.string().describe("The context to use when making the decision."),
question: z.string()
.refine((question) => question.trim().length > 0, "The question must not be empty."),
const questionSchema = z.object({
question: z.string().regex(/\S/, "The question must not be empty."),
choices: choicesSchema,
}).strict();
const sharedFields = {
context: z.string().describe("The context to use when making the decision."),
...(mode === "labels" && imagesEnabled ? {
imagePaths: z.array(z.string().min(1)).min(1)
.describe("Image file paths inside the configured image root, in display order.")
.optional(),
} : {}),
}).strict();
};
const questionsSchema = z.fromJSONSchema({
type: "object",
propertyNames: { type: "string", pattern: "^(?!__proto__$).+$" },
minProperties: 1,
additionalProperties: z.toJSONSchema(questionSchema),
}) as z.ZodType<Record<string, z.infer<typeof questionSchema>>>;
return {
choose: z.object({ ...sharedFields, ...questionSchema.shape }).strict(),
chooseMany: z.object({ ...sharedFields, questions: questionsSchema }).strict(),
};
}

function toolDescription(mode: ChoiceMode): string {
Expand Down Expand Up @@ -100,6 +111,18 @@ Readonly<Record<string, number | null>> {
[key, score === -Infinity ? null : score]));
}

function wireDecision(decision: Decision<string>) {
return {
choice: decision.choice,
distribution: decision.distribution,
scores: wireScores(decision.scores),
margin: decision.margin,
entropy: decision.entropy,
boundaryTokens: decision.boundaryTokens,
...(decision.usage === undefined ? {} : { usage: decision.usage }),
};
}

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";
Expand All @@ -112,11 +135,12 @@ export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpS
}

const server = new McpServer({ name: "choosekit-mcp", version: packageVersion });
const schemas = inputSchemas(backend, mode, options.imageLoader !== undefined);
server.registerTool(
"choose",
{
description: toolDescription(mode),
inputSchema: inputSchema(backend, mode, options.imageLoader !== undefined),
inputSchema: schemas.choose,
outputSchema: decisionSchema,
annotations: {
readOnlyHint: true,
Expand All @@ -135,23 +159,40 @@ export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpS
...(images === undefined ? {} : { images }),
signal: ctx.mcpReq.signal,
}) as Decision<string>;
const structuredContent: {
choice: string;
distribution: 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: wireScores(decision.scores),
margin: decision.margin,
entropy: decision.entropy,
boundaryTokens: decision.boundaryTokens,
...(decision.usage === undefined ? {} : { usage: decision.usage }),
const structuredContent = wireDecision(decision);
return {
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
structuredContent,
};
} catch (error) {
return errorResult(error, ctx.mcpReq.signal);
}
},
);
server.registerTool(
"chooseMany",
{
description: "Answer independent named questions about the same context. Each question is scored separately. " + toolDescription(mode),
inputSchema: schemas.chooseMany,
outputSchema: z.record(z.string(), decisionSchema),
annotations: {
readOnlyHint: true,
openWorldHint: backend === "openrouter",
},
},
async ({ context, questions, imagePaths }, ctx) => {
try {
const images = imagePaths === undefined
? undefined
: await options.imageLoader!(imagePaths as readonly string[], ctx.mcpReq.signal);
const decisions = await chooseMany(chooser, {
context,
questions,
...(images === undefined ? {} : { images }),
signal: ctx.mcpReq.signal,
});
const structuredContent = Object.fromEntries(Object.entries(decisions)
.map(([name, decision]) => [name, wireDecision(decision)]));
return {
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
structuredContent,
Expand Down
31 changes: 30 additions & 1 deletion packages/choosekit-mcp/tests/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ test("defaults to llama.cpp and lists tools without contacting it", async (t) =>

const response = await process_.rpc("tools/list");

assert.deepEqual(response.result.tools.map((tool) => tool.name), ["choose"]);
assert.deepEqual(response.result.tools.map((tool) => tool.name), ["choose", "chooseMany"]);
assert.ok(process_.lines.length >= 2);
for (const line of process_.lines) assert.doesNotThrow(() => JSON.parse(line));
});
Expand Down Expand Up @@ -262,6 +262,23 @@ test("serves an Ollama image choice through its configured chat endpoint", async
assert.equal(requests[0].url, "/api/chat");
assert.equal(requests[0].body.model, "fixture-model");
assert.deepEqual(requests[0].body.messages[0].images, [image.toString("base64")]);
const many = await process_.rpc("tools/call", {
name: "chooseMany",
arguments: {
context: request.context,
imagePaths: ["example.png"],
questions: {
route: { question: request.question, choices: request.choices },
urgency: { question: "How urgent is this?", choices: { low: "Can wait", high: "Needs immediate attention" } },
},
},
});
assert.equal(many.result.structuredContent.route.choice, "technical");
assert.equal(many.result.structuredContent.urgency.choice, "high");
assert.equal(requests.length, 3);
for (const received of requests.slice(1)) {
assert.deepEqual(received.body.messages[0].images, [image.toString("base64")]);
}
});

test("serves a minimal-prefix choice using the configured llama.cpp endpoint", async (t) => {
Expand Down Expand Up @@ -326,5 +343,17 @@ test("serves a minimal-prefix choice using the configured llama.cpp endpoint", a
assert.ok(scoredTokenIds.includes("t".charCodeAt(0) + 1));
assert.ok(!scoredTokenIds.includes("A".charCodeAt(0) + 1));
assert.ok(requests.some((request) => request.body.model === "fixture-model"));
const many = await process_.rpc("tools/call", {
name: "chooseMany",
arguments: {
context: request.context,
questions: {
route: { question: request.question, choices: request.choices },
followup: { question: "Who should investigate?", choices: request.choices },
},
},
});
assert.equal(many.result.structuredContent.route.choice, "billing");
assert.equal(many.result.structuredContent.followup.choice, "billing");
for (const line of process_.lines) assert.doesNotThrow(() => JSON.parse(line));
});
Loading
Loading