Initial commit: qwen delegation MCP server, tracker setup, research
- qwen_delegate MCP tool: src/qwen-delegate.ts (testable core, subprocess spawn/timeout/parse) + src/qwen-delegate-server.ts (thin MCP stdio wiring) - test/qwen-delegate.test.ts (node:test, mocked spawn) - docs/agents/* + CLAUDE.md from /setup-matt-pocock-skills (Gitea issue tracker via tea CLI, default triage labels, single-context domain docs) - research/qwen-mcp-delegation.md, corrected after confirming qwen-code runs natively on Windows (no WSL) against a local OpenAI-compatible proxy Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRnb5Gqdu7gVTQrwAqFdfJ
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.leankg/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Issues live as Gitea issues on `git.arthurerlich.de/haylan/delegate-ai-mcp`, managed via the `tea` CLI. See `docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
Default five canonical labels (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Single-context layout: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Domain Docs
|
||||||
|
|
||||||
|
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||||
|
|
||||||
|
## Before exploring, read these
|
||||||
|
|
||||||
|
- **`CONTEXT.md`** at the repo root, or
|
||||||
|
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||||
|
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||||
|
|
||||||
|
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
Single-context repo (most repos):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── docs/adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CONTEXT-MAP.md
|
||||||
|
├── docs/adr/ ← system-wide decisions
|
||||||
|
└── src/
|
||||||
|
├── ordering/
|
||||||
|
│ ├── CONTEXT.md
|
||||||
|
│ └── docs/adr/ ← context-specific decisions
|
||||||
|
└── billing/
|
||||||
|
├── CONTEXT.md
|
||||||
|
└── docs/adr/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the glossary's vocabulary
|
||||||
|
|
||||||
|
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||||
|
|
||||||
|
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||||
|
|
||||||
|
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Issue tracker: Gitea
|
||||||
|
|
||||||
|
Issues and specs for this repo live as issues on this repo's Gitea instance (`git.arthurerlich.de`, `haylan/delegate-ai-mcp`). Use the [`tea`](https://gitea.com/gitea/tea) CLI for all operations — it auto-detects the repo and remote login from `git remote -v`/`tea login list` when run inside this clone.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `tea issue create --title "..." --description "..."` (`-L` to add labels, `-a` to assign).
|
||||||
|
- **Read an issue**: `tea issue <index> --comments`.
|
||||||
|
- **List issues**: `tea issue list --state open --labels "..." --fields index,title,state,labels,comments`.
|
||||||
|
- **Comment on an issue**: `tea comment <index> "..."` (shorthand for `tea comment add`).
|
||||||
|
- **Apply / remove labels**: `tea issue edit <index> --add-labels "..."` / `--remove-labels "..."`.
|
||||||
|
- **Close**: `tea issue close <index>`, then `tea comment <index> "..."` first if a closing note is needed (`tea issue close` has no `--comment` flag).
|
||||||
|
|
||||||
|
## Pull requests as a triage surface
|
||||||
|
|
||||||
|
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
|
||||||
|
|
||||||
|
When set to `yes`, PRs run through the same labels/states as issues, using the `tea pull` equivalents (`tea pull list`, `tea pull <index> --comments`, `tea issue edit <index> --add-labels`/`--remove-labels` — labels/comments are shared machinery between issues and PRs in Gitea's API). Gitea shares one number space across issues and PRs, so a bare `#42` may be either — `tea issue <n>` and `tea pull <n>` both resolve by index; try the one implied by context.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Gitea issue: `tea issue create --title "..." --description "..."`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run `tea issue <index> --comments`.
|
||||||
|
|
||||||
|
## Wayfinding operations
|
||||||
|
|
||||||
|
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
|
||||||
|
|
||||||
|
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `tea issue create --title "..." --labels wayfinder:map`.
|
||||||
|
- **Child ticket**: an issue referencing the map. Gitea's CLI/API here has no native sub-issue or issue-dependency graph like GitHub's, so track structure in the body: put `Part of #<map>` at the top of the child body, and keep a task list of children in the map body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, assign the ticket to the driving dev (`tea issue edit <n> --add-assignees <user>`).
|
||||||
|
- **Blocking**: no native dependency graph — use a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker referenced there is closed (check each with `tea issue <n> --fields state`).
|
||||||
|
- **Frontier query**: list the map's open children (`tea issue list --state open --labels wayfinder:<type>`, cross-referenced against the map's task list), drop any with an open blocker (per the `Blocked by` line) or an assignee; first in map order wins.
|
||||||
|
- **Claim**: `tea issue edit <n> --add-assignees <your-gitea-username>` — the session's first write.
|
||||||
|
- **Resolve**: `tea comment <n> "<answer>"`, then `tea issue close <n>`, then append a context pointer to the map's Decisions-so-far.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Triage Labels
|
||||||
|
|
||||||
|
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||||
|
|
||||||
|
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||||
|
| --------------------------- | --------------------- | ----------------------------------------- |
|
||||||
|
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||||
|
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||||
|
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||||
|
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||||
|
| `wontfix` | `wontfix` | Will not be actioned |
|
||||||
|
|
||||||
|
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||||
|
|
||||||
|
These labels must exist in the Gitea repo before they can be applied — check with `tea label list`, create missing ones with `tea label create --name "..." --color "..."`.
|
||||||
|
|
||||||
|
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"skills-mcp": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"skills-mcp",
|
||||||
|
"-s",
|
||||||
|
"/absolute/path/to/skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1199
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "delegate-ai-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "MCP server that delegates light work from Claude Code to a local qwen-code CLI.",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/qwen-delegate-server.ts",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Delegating "light work" from Claude Code to a local qwen (WSL) harness via MCP
|
||||||
|
|
||||||
|
Researched 2026-09-06. Primary sources cited inline; no WSL/qwen commands were executed (none available in this environment) — see Open Questions at the end for what to verify locally.
|
||||||
|
|
||||||
|
> **Update (built, same day):** the WSL assumption below was wrong. `qwen-code` is installed
|
||||||
|
> natively on Windows (`C:\Users\aerli\AppData\Local\qwen-code\bin\qwen.cmd`, on PATH as `qwen`),
|
||||||
|
> backed by an OpenAI-compatible endpoint at `http://proxy-ai.home/v1` via `OMNIROUTE_API_KEY`
|
||||||
|
> (already configured in `~/.qwen/settings.json`). No WSL hop needed — Candidate 4's shell-out
|
||||||
|
> plan applies directly on Windows. Built as a Node/TS MCP server in this repo
|
||||||
|
> (`src/qwen-delegate-server.ts` + `src/qwen-delegate.ts`), registered globally via
|
||||||
|
> `claude mcp add --scope user`, with tests in `test/qwen-delegate.test.ts`. Live latency is
|
||||||
|
> real — a trivial one-word prompt took 3-6 minutes end to end — so the tool uses a generous
|
||||||
|
> (10 min) timeout rather than trying to enforce "light" in code.
|
||||||
|
|
||||||
|
## Summary / Recommendation
|
||||||
|
|
||||||
|
- **Candidate 1 (`llm-wrapper-mcp-server`)**: viable *only if* you route through OpenRouter's hosted API. It talks HTTP to an OpenAI-style `/chat/completions`-shaped endpoint (default `https://openrouter.ai/api/v1`, overridable via `LLM_API_BASE_URL`). It does **not** shell out to a CLI, and qwen (the `qwen` CLI/harness you run in WSL) has no HTTP endpoint of its own — so this project doesn't connect to your actual setup unless you first stand up an OpenAI-compatible server in front of qwen (e.g. run the underlying qwen3.8 weights via Ollama/vLLM instead of the `qwen` CLI). Verdict: **not directly applicable to a `wsl qwen -p "..."` CLI harness**; only viable if you bypass the `qwen` CLI and serve the model via Ollama/vLLM.
|
||||||
|
- **Candidate 2 (`harness/mcp-server`)**: confirmed **dead end** — this is Harness.io's CI/CD platform MCP server, unrelated to LLM delegation or "qwen harness." Pure name collision.
|
||||||
|
- **Candidate 3 (qwen-code CLI)**: this is almost certainly what the user means by "qwen harness." It's Apache-2.0, originally forked from Gemini CLI, and does have a genuine headless/scriptable mode (`qwen -p "<prompt>"`) plus MCP **client** support and a `settings.json` `modelProviders` mechanism that *can* point at a local OpenAI-compatible endpoint (Ollama/vLLM) if that's how the qwen3.8 model is actually being served. It is not itself an MCP *server* for arbitrary delegation (though it has an experimental `qwen serve` HTTP+SSE daemon mode for its own ACP sessions, not a general MCP tool endpoint).
|
||||||
|
- **Candidate 4 (generic/custom MCP proxy)**: **this is the realistic path.** There is no first-party or well-maintained "shell out to a CLI and return stdout as an MCP tool" server in the official `modelcontextprotocol/servers` repo. The pragmatic, low-effort solution is a ~50-line custom MCP server (Python SDK or TypeScript SDK) with one tool (e.g. `qwen_delegate`) that runs `wsl.exe qwen -p "<prompt>"` (or `wsl.exe bash -lc "qwen -p '...'"`), captures stdout, and returns it as the tool result.
|
||||||
|
- **Candidate 5 (Claude Code routing)**: Claude Code has **no built-in model-routing/delegation config** — there's no "route X to tool Y" setting. Routing to an MCP tool for "light work" is purely a **prompted convention**: you write a rule into `CLAUDE.md` (e.g. "for simple lookups/light edits, call the `qwen_delegate` MCP tool instead of doing it yourself") and Claude Code's own judgment (as an LLM reading its system/project instructions) decides when to invoke the tool. MCP server registration itself (`.mcp.json` / `claude mcp add`) is well-documented and directly supports a stdio server whose `command` is `wsl.exe`.
|
||||||
|
|
||||||
|
**Recommended concrete plan:** build the minimal custom MCP server described in Candidate 4, register it as a project-scoped stdio server in `.mcp.json` with `command: wsl.exe`, and add an explicit routing instruction to `CLAUDE.md`. Do not adopt `llm-wrapper-mcp-server` unless you decide to serve qwen3.8 via Ollama/vLLM behind an OpenAI-compatible endpoint instead of the `qwen` CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Candidate 1: matdev83/llm-wrapper-mcp-server
|
||||||
|
|
||||||
|
Source: [GitHub README](https://github.com/matdev83/llm-wrapper-mcp-server), [PyPI](https://pypi.org/project/llm-wrapper-mcp-server/) (PyPI page failed to render for automated fetch/search beyond confirming it exists under this name and license — see Open Questions).
|
||||||
|
|
||||||
|
- **Backends supported**: Primarily OpenRouter.ai. The README's own description: *"Allow any MCP-capable LLM agent to communicate with or delegate tasks to any other LLM available through the OpenRouter.ai API."* Base URL defaults to `https://openrouter.ai/api/v1` and is overridable via `LLM_API_BASE_URL` env var or `--llm-api-base-url` CLI flag — so in principle it can point at any OpenAI-compatible HTTP endpoint, **but it is an HTTP client, not a CLI-shell-out wrapper.**
|
||||||
|
- **CLI shell-out**: No evidence of any capability to invoke an external CLI process (like `qwen`). It only ever makes HTTP requests.
|
||||||
|
- **MCP tool surface**: One tool, `llm_call(prompt: str, model: str | None)`. Standard MCP methods (`initialize`, `tools/list`, `resources/list`) via stdio/JSON-RPC.
|
||||||
|
- **Configuration**: env vars `OPENROUTER_API_KEY` (required), `LLM_API_BASE_URL` (optional); CLI flags `--model` (default `perplexity/llama-3.1-sonar-small-128k-online`), `--llm-api-base-url`, `--log-level`; `.env` file support via `python-dotenv`.
|
||||||
|
- **License**: MIT.
|
||||||
|
- **Maintenance**: ~42 commits on main, 0 stars/forks/watchers visible on the repo page — very low adoption, no evidence of active community use.
|
||||||
|
- **Install/run**: `pip install llm-wrapper-mcp-server`, then `python -m llm_wrapper_mcp_server [OPTIONS]`.
|
||||||
|
- **Dependencies**: pydantic, requests, tiktoken, llm-accounting.
|
||||||
|
|
||||||
|
**Verdict: not applicable as-is.** It solves "delegate to another LLM over HTTP," but qwen as a CLI/WSL harness has no HTTP API to call. It becomes viable only if you additionally stand up an OpenAI-compatible server (Ollama or vLLM) serving the qwen3.8 weights directly, bypassing the `qwen` CLI/harness entirely — which changes the architecture the user described (they specifically want to drive the `qwen` CLI harness, which per Candidate 3 has its own search/RAG module wired in that a bare model server wouldn't have).
|
||||||
|
|
||||||
|
## Candidate 2: harness/mcp-server
|
||||||
|
|
||||||
|
Source: [GitHub README](https://github.com/harness/mcp-server).
|
||||||
|
|
||||||
|
Confirmed **dead end / naming collision**. This is Harness.io's own MCP server for the Harness CI/CD/DevOps platform: *"An MCP (Model Context Protocol) server that gives AI agents full access to the Harness.io platform through 11 consolidated tools and 243 resource types"* — pipelines, GitOps, feature flags, cloud cost management, security testing. It has nothing to do with LLM delegation, "qwen harness," or routing prompts to a secondary model. Note explicitly for the user: the word "harness" here refers to the company Harness Inc., not to a "coding harness" wrapping an LLM.
|
||||||
|
|
||||||
|
## Candidate 3: The "qwen harness" / qwen-code CLI
|
||||||
|
|
||||||
|
Source: [QwenLM/qwen-code GitHub](https://github.com/QwenLM/qwen-code), [Qwen Code Docs — Configuration/Settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/), [Qwen Code Docs — Model Providers](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/), related issue [QwenLM/qwen-code#3384](https://github.com/QwenLM/qwen-code/issues/3384) (OpenAI-compatible local LLM support).
|
||||||
|
|
||||||
|
- This is almost certainly the tool the user means by "qwen harness"/"qwen code": a coding-agent CLI, **originally forked from Google's Gemini CLI** ("This project was originally based on Google Gemini CLI v0.8.2"; the project stopped syncing with upstream after Qwen Code v0.1).
|
||||||
|
- **License**: Apache 2.0.
|
||||||
|
- **MCP role**: Qwen Code is an **MCP client** (it can call out to MCP servers itself — README lists "MCP, Plan Mode, LSP Integration" as capabilities). It is **not** documented as exposing a general-purpose MCP *server* interface for other agents to call into. There is a separate experimental `qwen serve` mode described as a "shared agent session over HTTP+SSE (ACP) — multiple clients, one agent," which is its own Agent Communication Protocol daemon, not a standard MCP tool endpoint — do not conflate the two.
|
||||||
|
- **Non-interactive/scriptable mode**: Yes — `qwen -p "<prompt>"` is documented for "Scripts, CI/CD, batch processing — no UI." This is the flag to wrap in a shell-out MCP tool.
|
||||||
|
- **Backend flexibility**: README states support for "OpenAI, Anthropic, Gemini, and Qwen APIs. Any third-party provider or local model (Ollama / vLLM). Switch at runtime." The Model Providers doc confirms `modelProviders` entries in `settings.json` can point at `http://localhost:11434/v1` (Ollama) or `http://localhost:8000/v1` (vLLM), and a `contextWindowSize` override exists in `generationConfig` for providers whose effective limit differs from qwen-code's built-in defaults — relevant since the user's qwen3.8 instance has a ~127k window that may need an explicit override rather than relying on qwen-code's name-based default table.
|
||||||
|
|
||||||
|
**Verdict: this is the real target to wrap**, via its `-p` non-interactive flag, invoked through `wsl.exe qwen -p "..."` from Windows. It is not itself pluggable as an MCP server for Claude Code to call directly — you still need a small MCP shim.
|
||||||
|
|
||||||
|
## Candidate 4: Generic MCP LLM-proxy alternatives / custom server
|
||||||
|
|
||||||
|
Sources: [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers), [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk), [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk).
|
||||||
|
|
||||||
|
- The official `modelcontextprotocol/servers` repo has no first-party "generic OpenAI-compatible LLM proxy" or "shell out to CLI" server; it only ships reference servers (filesystem, git, memory, fetch, and the "Everything" test/reference server demonstrating prompts/resources/tools generically, not LLM delegation specifically).
|
||||||
|
- No well-maintained community equivalent surfaced in this repo either — the space is thin, matching the low-star `llm-wrapper-mcp-server` finding above.
|
||||||
|
- **Given qwen has no native OpenAI-compatible HTTP API when run as the `qwen` CLI harness (only when the underlying model is separately served via Ollama/vLLM), writing a minimal custom MCP server that shells out `wsl qwen -p "<prompt>"` and returns stdout is the more realistic path** than adapting either of the two candidates the user found. This is a small, single-tool stdio MCP server (using the official Python or TypeScript SDK) — roughly:
|
||||||
|
- one tool, e.g. `qwen_delegate(prompt: string) -> string`
|
||||||
|
- implementation: spawn `wsl.exe` (or `wsl.exe -e bash -lc "qwen -p '...'"` if quoting/shell needs matter) as a subprocess, capture stdout/stderr, return stdout (trimmed) as the tool result, with a timeout and basic error surfacing (non-zero exit code, stderr content) since qwen's research/SearXNG module may be slow or occasionally fail.
|
||||||
|
- this keeps qwen's own internet/search capability intact (unlike swapping to a bare Ollama/vLLM completions endpoint, which would lose that SearXNG-backed research tool entirely).
|
||||||
|
|
||||||
|
## Candidate 5: Instructing Claude Code to route to the MCP tool
|
||||||
|
|
||||||
|
Sources: [Claude Code MCP docs](https://code.claude.com/docs/en/mcp) (redirected from `docs.claude.com/en/docs/claude-code/mcp`).
|
||||||
|
|
||||||
|
- **No built-in routing/delegation config exists.** Claude Code does not have a mechanism like "send requests matching pattern X to MCP tool Y automatically." Any "route light work to qwen" behavior is a **prompted convention**: you instruct Claude Code (via `CLAUDE.md`, project instructions, or a slash command) to call a specific MCP tool under specific conditions, and it's up to Claude's own judgment in the moment to follow that instruction — there's no deterministic dispatcher.
|
||||||
|
- **MCP server registration** (this part *is* concrete/configurable):
|
||||||
|
- Project-scoped server lives in `.mcp.json` at the repo root, checked into git and shared with the team.
|
||||||
|
- CLI: `claude mcp add --transport stdio <name> --scope project -- <command> <args...>` — the `--` separator is required before the server command so Claude Code doesn't try to parse the server's own flags.
|
||||||
|
- Example directly confirmed in the docs for a Windows/WSL stdio server:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"qwen-delegate": {
|
||||||
|
"command": "wsl.exe",
|
||||||
|
"args": ["bash", "-c", "cd ${CLAUDE_PROJECT_DIR} && ./qwen-mcp-server.sh"],
|
||||||
|
"env": { "SOME_VAR": "${SOME_VAR:-default}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Scopes, in precedence order: **local** (`~/.claude.json`, current project only, not shared) > **project** (`.mcp.json`, shared via git) > **user** (`~/.claude.json`, all projects) > plugin-provided > claude.ai connectors.
|
||||||
|
- `${CLAUDE_PROJECT_DIR}`, `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}` are available as special expansion variables in `.mcp.json`; general env vars use `${VAR}` / `${VAR:-default}`.
|
||||||
|
- Project-scoped servers require one-time approval in interactive sessions (`claude mcp reset-project-choices` to reset; `enabledMcpjsonServers`/`disabledMcpjsonServers` in settings to pre-approve/block); non-interactive (`claude -p`) sessions load them without prompting unless `--strict-mcp-config` is passed.
|
||||||
|
|
||||||
|
**Verdict: viable and directly supported** — a stdio MCP server whose `command` is `wsl.exe` is an explicitly documented pattern, not a workaround.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended concrete plan
|
||||||
|
|
||||||
|
1. **Confirm qwen's real invocation locally first** (see Open Questions — this research could not run WSL commands).
|
||||||
|
2. **Write a minimal custom MCP server** (Python SDK, since the ecosystem here already leans Python) with a single tool `qwen_delegate(prompt: str, timeout_s: int = 60) -> str` that:
|
||||||
|
- Spawns `wsl.exe qwen -p "<prompt>"` (or wraps in `bash -lc` if PATH/profile setup is needed for `qwen` to resolve inside WSL).
|
||||||
|
- Captures and returns stdout; on failure, returns exit code + stderr as an MCP error/tool-result so Claude Code can see it and fall back to doing the work itself.
|
||||||
|
- Enforces a timeout, since a SearXNG-backed research call could hang.
|
||||||
|
3. **Register it as a project-scoped stdio server** in `.mcp.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"qwen-delegate": {
|
||||||
|
"command": "wsl.exe",
|
||||||
|
"args": ["python3", "/path/inside/wsl/to/qwen_mcp_server.py"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
or, if you keep the MCP server itself on the Windows side and only shell out to WSL for the actual qwen call, register it as a normal local Python/Node stdio command instead (`command: python`, `args: ["qwen_mcp_server.py"]`) and have that script call `wsl.exe qwen -p ...` internally — this is likely simpler to debug than nesting the whole MCP server inside WSL.
|
||||||
|
4. **Add a routing instruction to `CLAUDE.md`**, e.g.:
|
||||||
|
> For light, low-stakes work (simple lookups, quick web/research questions, boilerplate text generation, straightforward single-file edits under ~50 lines) — prefer the `qwen_delegate` MCP tool over doing it yourself, to save budget. Use your own judgment for anything requiring deep codebase context, multi-file changes, or high-stakes correctness.
|
||||||
|
|
||||||
|
Remember this is a *prompted convention only* — there is no Claude Code config that enforces it mechanically.
|
||||||
|
|
||||||
|
## Open questions / things to verify locally (could not run WSL here)
|
||||||
|
|
||||||
|
- Run `wsl qwen --help` to confirm the exact non-interactive flag name and behavior (`-p` vs `--prompt`, whether it accepts stdin piping, whether output is plain text or includes ANSI/formatting that needs stripping).
|
||||||
|
- Confirm whether `qwen` on this machine is literally `QwenLM/qwen-code`, or Qwen-Agent, or an Ollama-served model with a custom wrapper script — run `which qwen` / check its shebang or install location inside WSL, since this determines whether the `modelProviders`/`settings.json` local-endpoint config from the qwen-code docs actually applies.
|
||||||
|
- If it is qwen-code, check `~/.qwen/settings.json` (or project-level `.qwen/settings.json`) inside WSL for its current `modelProviders` / `contextWindowSize` config, to confirm the ~127k context and SearXNG research module are wired up the way you expect, and to get the exact provider name to reference if you ever want to call it differently.
|
||||||
|
- Decide whether the MCP server process itself should live on the Windows side (simpler `wsl.exe` subprocess call, easier to iterate with Windows tooling) or inside WSL (avoids a second interop hop but complicates the `.mcp.json` command line) — confirm which one starts up reliably by testing `claude mcp add ... -- wsl.exe ...` per the doc's example and checking `claude mcp list` / `/mcp` inside a session.
|
||||||
|
- Verify `wsl.exe` is on PATH for the account Claude Code runs under, and that no interactive WSL prompt (first-run distro setup, sudo password, etc.) will block a non-interactive MCP tool call.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// MCP stdio server exposing a single tool, qwen_delegate, that runs a prompt through the
|
||||||
|
// local qwen-code CLI (see research/qwen-mcp-delegation.md). Register globally with:
|
||||||
|
// claude mcp add --scope user qwen-delegate -- node <path-to-this-file>
|
||||||
|
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { delegateToQwen } from "./qwen-delegate.ts";
|
||||||
|
|
||||||
|
const server = new McpServer({ name: "qwen-delegate", version: "0.1.0" });
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"qwen_delegate",
|
||||||
|
{
|
||||||
|
title: "Delegate to qwen",
|
||||||
|
description:
|
||||||
|
"Delegate a light, low-stakes prompt (simple lookups, quick research, boilerplate text, " +
|
||||||
|
"small single-file edits) to the local qwen-code CLI, offloading it from the main session. " +
|
||||||
|
"Can take several minutes to respond — only use for work that doesn't block on a fast reply.",
|
||||||
|
inputSchema: {
|
||||||
|
prompt: z.string().describe("The prompt to send to qwen, non-interactively."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ prompt }) => {
|
||||||
|
const result = await delegateToQwen(prompt);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result.output }],
|
||||||
|
isError: !result.ok,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Core delegation logic: spawn the qwen-code CLI non-interactively and capture its reply.
|
||||||
|
// Kept separate from the MCP server wiring (qwen-delegate-server.ts) so it's unit-testable
|
||||||
|
// without a live MCP connection or a live qwen endpoint.
|
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
|
||||||
|
export interface DelegateResult {
|
||||||
|
ok: boolean;
|
||||||
|
output: string; // stdout (ok) or combined error detail (!ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelegateOptions {
|
||||||
|
/** Milliseconds before giving up. qwen-code has been observed taking 3-6 min for a trivial
|
||||||
|
* prompt against a local model proxy, so default generous — see research/qwen-mcp-delegation.md. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Injectable for tests. Defaults to node:child_process's spawn. */
|
||||||
|
spawnFn?: typeof spawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `qwen -p "<prompt>"` non-interactively and returns its trimmed stdout.
|
||||||
|
* qwen-code prints startup warnings (e.g. failed MCP sub-servers) to stderr; those are
|
||||||
|
* ignored on success and surfaced only when the run itself fails.
|
||||||
|
*/
|
||||||
|
export function delegateToQwen(
|
||||||
|
prompt: string,
|
||||||
|
options: DelegateOptions = {},
|
||||||
|
): Promise<DelegateResult> {
|
||||||
|
const { timeoutMs = DEFAULT_TIMEOUT_MS, spawnFn = spawn } = options;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child: ChildProcess = spawnFn("qwen", ["-p", prompt], {
|
||||||
|
shell: true, // qwen.cmd on Windows needs a shell to resolve
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
child.kill();
|
||||||
|
resolve({ ok: false, output: `qwen timed out after ${timeoutMs}ms` });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => (stdout += chunk));
|
||||||
|
child.stderr?.on("data", (chunk) => (stderr += chunk));
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ ok: false, output: `failed to start qwen: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ ok: true, output: stdout.trim() });
|
||||||
|
} else {
|
||||||
|
const detail = stderr.trim() || stdout.trim() || "(no output)";
|
||||||
|
resolve({ ok: false, output: `qwen exited with code ${code}: ${detail}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { delegateToQwen } from "../src/qwen-delegate.ts";
|
||||||
|
|
||||||
|
// Fake child_process.spawn: returns an EventEmitter with stdout/stderr sub-emitters and a
|
||||||
|
// kill() spy, driven manually by each test via the returned `child` handle.
|
||||||
|
function fakeSpawn() {
|
||||||
|
const child: any = new EventEmitter();
|
||||||
|
child.stdout = new EventEmitter();
|
||||||
|
child.stderr = new EventEmitter();
|
||||||
|
child.killed = false;
|
||||||
|
child.kill = () => {
|
||||||
|
child.killed = true;
|
||||||
|
};
|
||||||
|
const spawnFn = () => child;
|
||||||
|
return { spawnFn, child };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolves ok with trimmed stdout on exit code 0", async () => {
|
||||||
|
const { spawnFn, child } = fakeSpawn();
|
||||||
|
const promise = delegateToQwen("hi", { spawnFn });
|
||||||
|
|
||||||
|
child.stdout.emit("data", " OK\n");
|
||||||
|
child.emit("close", 0);
|
||||||
|
|
||||||
|
assert.deepEqual(await promise, { ok: true, output: "OK" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves not-ok with stderr detail on non-zero exit", async () => {
|
||||||
|
const { spawnFn, child } = fakeSpawn();
|
||||||
|
const promise = delegateToQwen("hi", { spawnFn });
|
||||||
|
|
||||||
|
child.stderr.emit("data", "API Key fehlt.\n");
|
||||||
|
child.emit("close", 1);
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.output, /exited with code 1/);
|
||||||
|
assert.match(result.output, /API Key fehlt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves not-ok when spawn itself fails (e.g. qwen not on PATH)", async () => {
|
||||||
|
const { spawnFn, child } = fakeSpawn();
|
||||||
|
const promise = delegateToQwen("hi", { spawnFn });
|
||||||
|
|
||||||
|
child.emit("error", new Error("ENOENT"));
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.output, /failed to start qwen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("times out and kills the child if it never exits", async () => {
|
||||||
|
const { spawnFn, child } = fakeSpawn();
|
||||||
|
const promise = delegateToQwen("hi", { spawnFn, timeoutMs: 10 });
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.output, /timed out/);
|
||||||
|
assert.equal(child.killed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores stderr warnings when the run still succeeds", async () => {
|
||||||
|
const { spawnFn, child } = fakeSpawn();
|
||||||
|
const promise = delegateToQwen("hi", { spawnFn });
|
||||||
|
|
||||||
|
child.stderr.emit("data", "Warning: MCP server(s) failed to start: omniroute-search\n");
|
||||||
|
child.stdout.emit("data", "OK\n");
|
||||||
|
child.emit("close", 0);
|
||||||
|
|
||||||
|
assert.deepEqual(await promise, { ok: true, output: "OK" });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user