diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml new file mode 100644 index 0000000..261bdf6 --- /dev/null +++ b/.github/workflows/publish-mcp.yml @@ -0,0 +1,30 @@ +name: Publish MCP to npm + +on: + push: + tags: + - "mcp-v*" + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/choosekit-mcp + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: npm ci + - run: npm run check + - name: Verify release tag + run: | + node --input-type=module -e "import p from './package.json' with { type: 'json' }; if (process.env.GITHUB_REF_NAME !== 'mcp-v' + p.version) { throw new Error('Tag must match package version'); }" + - run: npm publish --access public diff --git a/packages/choosekit-mcp/README.md b/packages/choosekit-mcp/README.md index 5f28a10..1440257 100644 --- a/packages/choosekit-mcp/README.md +++ b/packages/choosekit-mcp/README.md @@ -106,6 +106,8 @@ Ask independent named questions about the same context: The result maps each question name to its decision, including separate token counts when available. Choice limits apply to each question. +Optional `concurrency` limits simultaneous questions. Omit it to run all questions concurrently. + ### Images To score images, add `imagePaths` in `labels` mode. Both tools accept this field alongside `context`; `chooseMany` shares the images across all questions. diff --git a/packages/choosekit-mcp/package-lock.json b/packages/choosekit-mcp/package-lock.json index a85b25b..72b6ef2 100644 --- a/packages/choosekit-mcp/package-lock.json +++ b/packages/choosekit-mcp/package-lock.json @@ -1,16 +1,16 @@ { "name": "choosekit-mcp", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "choosekit-mcp", - "version": "0.5.0", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/server": "2.0.0", - "choosekit": "^0.8.0", + "choosekit": "^0.9.0", "zod": "^4.6.4" }, "bin": { @@ -60,9 +60,9 @@ } }, "node_modules/choosekit": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/choosekit/-/choosekit-0.8.0.tgz", - "integrity": "sha512-3+E92te0MYmQ8H49DR4t2AFnM7L7o70bXHh6zE0kHpLfRyFoiss+zv6rddc0Z8gYSa9dkOqqx+9IdUn3ZtiXEw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/choosekit/-/choosekit-0.9.0.tgz", + "integrity": "sha512-wN+/xegOfJPFFzso+Zz6xqUF6ICSqJWTNpkI3WUcY/9PA7dLdQWI8kV5ALbZzniJNfFDjGmYSo27QRRgLin/ag==", "license": "Apache-2.0", "engines": { "node": ">=20" diff --git a/packages/choosekit-mcp/package.json b/packages/choosekit-mcp/package.json index 1238f37..fddd0fd 100644 --- a/packages/choosekit-mcp/package.json +++ b/packages/choosekit-mcp/package.json @@ -1,6 +1,6 @@ { "name": "choosekit-mcp", - "version": "0.5.0", + "version": "0.6.0", "description": "Use choosekit as an MCP server.", "license": "Apache-2.0", "author": { @@ -37,7 +37,7 @@ }, "dependencies": { "@modelcontextprotocol/server": "2.0.0", - "choosekit": "^0.8.0", + "choosekit": "^0.9.0", "zod": "^4.6.4" }, "devDependencies": { diff --git a/packages/choosekit-mcp/src/server.ts b/packages/choosekit-mcp/src/server.ts index d1c5873..e9f203f 100644 --- a/packages/choosekit-mcp/src/server.ts +++ b/packages/choosekit-mcp/src/server.ts @@ -69,7 +69,13 @@ function inputSchemas(backend: "llama-cpp" | "ollama" | "openrouter", mode: Choi }) as z.ZodType>>; return { choose: z.object({ ...sharedFields, ...questionSchema.shape }).strict(), - chooseMany: z.object({ ...sharedFields, questions: questionsSchema }).strict(), + chooseMany: z.object({ + ...sharedFields, + questions: questionsSchema, + concurrency: z.number().int().positive() + .describe("Maximum simultaneous questions. Omit to run all questions concurrently.") + .optional(), + }).strict(), }; } @@ -180,7 +186,7 @@ export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpS openWorldHint: backend === "openrouter", }, }, - async ({ context, questions, imagePaths }, ctx) => { + async ({ context, questions, imagePaths, concurrency }, ctx) => { try { const images = imagePaths === undefined ? undefined @@ -188,6 +194,7 @@ export function buildServer(chooser: Chooser, options: ServerOptions = {}): McpS const decisions = await chooseMany(chooser, { context, questions, + ...(concurrency === undefined ? {} : { concurrency }), ...(images === undefined ? {} : { images }), signal: ctx.mcpReq.signal, }); diff --git a/packages/choosekit-mcp/tests/server.test.mjs b/packages/choosekit-mcp/tests/server.test.mjs index 1224483..44e87c3 100644 --- a/packages/choosekit-mcp/tests/server.test.mjs +++ b/packages/choosekit-mcp/tests/server.test.mjs @@ -139,11 +139,38 @@ test("chooseMany starts independent questions together and loads shared images o assert.equal(loads, 1); for (const call of calls) { assert.equal(call.context, manyRequest.context); - assert.equal(call.images, images); + assert.deepEqual(call.images, images); assert.equal(call.signal, calls[0].signal); } }); +test("chooseMany respects the requested concurrency", async (t) => { + for (const concurrency of [1, 2]) { + await t.test(`concurrency ${concurrency}`, async (t) => { + let active = 0; + let peak = 0; + let calls = 0; + const chooser = createChooser(async () => { + calls++; + peak = Math.max(peak, ++active); + await new Promise(setImmediate); + active--; + return { logprobs: [-0.1, -2], boundaryTokens: 0 }; + }); + const connection = await connect(chooser); + t.after(() => connection.close()); + const questions = { ...manyRequest.questions, extra: manyRequest.questions.route }; + const response = await connection.rpc("tools/call", { + name: "chooseMany", arguments: { ...manyRequest, questions, concurrency }, + }); + assert.notEqual(response.result.isError, true); + assert.deepEqual(Object.keys(response.result.structuredContent), Object.keys(questions)); + assert.equal(calls, 3); + assert.equal(peak, concurrency); + }); + } +}); + test("chooseMany rejects invalid groups before calling the chooser", async (t) => { let calls = 0; const connection = await connect(async () => { calls++; }, { backend: "openrouter" }); @@ -152,6 +179,8 @@ test("chooseMany rejects invalid groups before calling the chooser", async (t) = for (const arguments_ of [ { ...manyRequest, questions: {} }, { ...manyRequest, model: "test-model" }, + ...[0, -1, 1.5, "2", null, Number.MAX_SAFE_INTEGER + 1] + .map((concurrency) => ({ ...manyRequest, concurrency })), { ...manyRequest, questions: { route: { ...manyRequest.questions.route, question: " " } } }, { ...manyRequest, questions: { route: { ...manyRequest.questions.route, model: "test-model" } } }, { ...manyRequest, questions: { route: { question: request.question, choices: tooMany } } },