Skip to main content
Glama
takuomi
by takuomi
README.md
# Pico AI Bridge (Unofficial)

English | [Japanese](https://github.com/takuomi/pico-ai-bridge/blob/main/README.ja.md)

**Unofficial project.** This bridge is not affiliated with or endorsed by Starling Protocol, the developer of Pico AI Server (formerly Pico AI Homelab).

Pico AI Bridge is a local stdio MCP server that lets Claude call models on your Pico AI Server. It uses readable JavaScript, Node.js built-ins, no runtime packages, and no build step.

## Requirements

- Pico AI Server on an Apple Silicon Mac, reachable from the machine running Claude. The Claude Desktop extension requires its URL at install; the Claude Code plugin defaults to `http://localhost:11434`.
- Claude Code plugin or manual configuration: Node.js 20+ on `PATH`.
- Claude Desktop `.mcpb`: uses Desktop's built-in Node.js.

## Tools

| Tool | Use |
| --- | --- |
| `pico_server_info` | Check connectivity and return version, name, build, and bundle identifier. |
| `pico_list_models` | Merge `/v1/models` and `/api/tags`; optionally inspect capabilities with `/api/show`. |
| `pico_responses` | Responses API generation and function calls. |
| `pico_chat_completions` | Chat completions generation. |
| `pico_ollama_chat` | Ollama chat generation and options. |
| `pico_compare_models` | Opt-in sequential comparison of two or more models. |

In default mode, every Pico response field used to extract or compute a result must have its expected JSON type when present and non-null; malformed fields return `unexpected_response` with the endpoint message. Absent or null optional fields remain null. The same rule applies to comparison rows. Raw mode returns parsed Pico bodies after API key redaction; if JSON contains a non-finite number or unsafe integer, it returns the original text after redaction. Tool-call argument JSON with an unsafe integer stays as text. Default mode reports `response_parse_failed` for such a 2xx body, and Pico HTTP errors keep the original text.

Every tool accepts `raw: true`. A single endpoint returns `structuredContent: {status, body}`: the HTTP status and parsed Pico body, or text when the body is not JSON. The text content contains the serialized body, or the body itself when it is a string. The configured API key is redacted in Pico body values and keys. Pico HTTP errors return `{source: "pico", status, body}` with `isError: true`. Raw `pico_list_models` returns `v1_models` and `api_tags` status/body pairs and, with `include_capabilities`, `api_show` keyed by the name sent to `/api/show`; any Pico HTTP error sets `isError: true`. Raw `pico_compare_models` returns a successful model's `status`, `body`, and `elapsed_ms`. A failed comparison row contains `error` and comparison continues; the outer result has no `isError`.

`pico_list_models` merges `/v1/models` and `/api/tags` by the Ollama model-name rule: a name without a tag means `:latest`. Each default-mode entry has `id`, `created`, and `owned_by` from `/v1/models` `data`, `name` and `tag_model` from `/api/tags`, `display_name` from the `/v1/models` `models` array, `status` from `status.value`, and `format`, `family`, `parameter_size`, `quantization_level`, `size_bytes`, and `modified_at` from `/api/tags`; `size_gb` is bytes divided by 1e9 and rounded to two decimals (missing values are `null`). With `include_capabilities`, the bridge calls `/api/show` once per merged model, using its `/api/tags` name or its `id` if absent there. A successful show adds `capabilities` and `context_length`; a Pico HTTP error adds `capabilities_error: {source, status, body}`. The result includes `partial_errors` items shaped `{path, error: {source, status, body}}` for listing endpoint HTTP errors. If both listing endpoints fail, it returns `{models: [], partial_errors}` with `isError: true`. A connection failure or timeout returns a bridge error. Successful malformed lists return `unexpected_response`: `/v1/models` needs `data` objects with string `id` and, when present, a `status` object whose `value` is a string when present and non-null (an absent or null `value` extracts as null), and an array of objects in `models` when present; `/api/tags` needs `models` objects with string `name`; `size`, when present, must be a finite number. In default mode, a successful non-object `/api/show` body, non-string `capabilities` array entries, a non-array `capabilities`, a non-object `model_info`, or a non-finite or nonnumeric `.context_length` returns `unexpected_response`. Absent or null optional fields remain null. Raw listing without capabilities does not validate list shapes. Raw listing with `include_capabilities` checks only that each 2xx `/v1/models` body has a `data` array of objects with string `id`, and each 2xx `/api/tags` body has a `models` array of objects with string `name`, so it can call `/api/show`; it does not validate `status`, `size`, or other fields.

For generation, chat completions require `prompt`, `messages`, images, or `system` alone; `extra.messages` also supplies input when none of those is given. Ollama accepts these inputs or omits `messages` entirely; supplied message items are sent as given. Comparison accepts exactly one of `prompt` or `messages`, or `images` alone, and sends a supplied `messages` array as Responses `input` when `api: "responses"`. Comparison `system` becomes Responses `instructions`; images attach as described below. For chat and Ollama generation, `system` prepends a system message. With `system` alone, the request contains only that message. Responses accepts optional `input` as a string or an array of items sent as given, plus optional `instructions`; it has no `messages` argument. When `input` is absent, the bridge omits it and passes Pico's result through. `images` attaches to the last user message: string content becomes text and image parts, array content gets image parts appended, and Ollama appends base64 strings to that message's `images`. An entry starting with `data:`, `http://`, or `https://` (ASCII case-insensitive, no leading whitespace) is a URI; anything else is a local file path. `~` and paths starting with `~/` are resolved against the home directory of the bridge process; other paths are used as given (relative paths resolve against its working directory). After classification, every Ollama data URI is parsed as a URL, removing ASCII tabs and newlines and trimming surrounding C0 controls and spaces; its fragment is then removed before finding the first comma. The payload after that comma is percent-decoded to bytes. If the trimmed media type ends in `;` followed by optional spaces and then `base64` (case-insensitive), those bytes are decoded with forgiving-base64, which accepts ASCII whitespace and optional padding. Ollama sends standard base64 of the resulting bytes. A percent sign without two ASCII hex digits remains a literal byte. Invalid URLs, missing commas, and invalid base64 return `invalid_arguments`. Image URLs pass through. With images and no other input, including an empty `messages` or `input` array, the bridge sends one user message containing only images. Numeric tool arguments require finite numbers and safe integers. A non-finite number or an integer above `Number.MAX_SAFE_INTEGER` in magnitude anywhere in the arguments, including inside `extra`, `messages`, `tools`, or `options`, is rejected because JSON would lose its value; invalid values return `invalid_arguments` before requests. Other target content shapes return `invalid_arguments` before image reads. With Ollama images, an existing non-array `images` field on the target user message also returns `invalid_arguments` before image reads. Local paths are read without checking image type; image URLs are not fetched. Chat `response_format`, Responses `json_schema` with required `json_schema_name` (as `text.format: {type: "json_schema", name: json_schema_name, schema: json_schema}`), and Ollama `format` are sent to Pico; the bridge does not check answers against schemas. Responses `previous_response_id` is sent as given. `tools` is sent in each API's format. All POST tools send `stream: false`; `extra.stream` conflicts. Other `extra` keys conflict when the tool already sends that field; conflicts return `invalid_arguments` before image reads or HTTP requests. The generation `model` argument is optional, and omission leaves model selection to Pico. GET tools accept neither `extra` nor `split_thinking`.

Default chat completions returns the first choice's `answer`, `refusal` (from `message.refusal`, or null), `reasoning`, `response_model`, `finish_reason`, `response_id`, `created`, `usage`, and `tool_calls` with `{id, type, name, arguments}` for function items or `{id, type, name, input}` for custom items; custom `input` stays free text. Responses concatenates all `output_text` parts and all `refusal` part strings in output order and returns `answer`, `refusal` (null when absent), `reasoning`, `response_model`, `status`, `response_id`, `created_at`, `completed_at`, `usage` including `cached_tokens`, `error`, `incomplete_details`, and `function_calls` with `{id, call_id, name, arguments, status}`, plus `custom_tool_calls` with `{id, call_id, name, input, status}`. Both call arrays are always present, and absent custom-call `id` and `status` become `null`. Ollama returns `message.content` as `answer`, plus `reasoning`, `response_model`, `finish_reason`, count `usage`, `durations_ms`, `created_at`, `done`, and `tool_calls` with `{id, name, arguments}`. Absent values are `null` where those fields are provided. A successful default comparison row has `model`, `answer`, `reasoning`, `response_model`, API-specific `tool_calls`, or `function_calls` and `custom_tool_calls` for Responses, `finish_reason` or `status`, `response_id` and `created` for chat, `response_id`, `created_at`, and `completed_at` for Responses, or `durations_ms`, `created_at`, and `done` for Ollama, `refusal` for chat and Responses, `usage` when Pico returns it (always `{prompt_eval_count, eval_count}` for Ollama, with nulls for absent counts), and `elapsed_ms`. Responses comparison rows also include `error` and `incomplete_details`, each `null` when absent from Pico. Failed comparisons have a per-row `error`. After a sent request times out, comparison waits for Pico to finish it before sending the next model, even when the concurrency limit is above one; MCP cancellation ends that wait and stops the comparison. Default generation and comparison check 2xx bodies: chat needs a nonempty `choices` array whose first item has a message with string `role`; its `content` may be a string (including an empty string), null, or absent, and null or absent content gives an empty answer. Chat `refusal` may be a string, null, or absent. Chat `reasoning_content` and `reasoning` may each be a string, `null`, or absent. Chat `tool_calls` may be an array, `null`, or absent; each array item needs string `id` and `type`. For `type: "custom"`, it needs an object `custom` with string `name` and free-text string `input`; every other type keeps the object `function` with string `name` and string `arguments` rule. Ollama needs a message with string `role` and string `content` (including an empty string); `thinking` may be a string, `null`, or absent. Ollama durations (`total_duration`, `load_duration`, `prompt_eval_duration`, `eval_duration`) must be finite numbers when present; null and absent values remain null. Ollama `tool_calls` may be an array, `null`, or absent; each array item must be an object with an object `function` and string `function.name`; `function.arguments` may be absent, null, an object, or a string (absent or null extracts as null). Responses needs an `output` array of objects with string `type`; each item's `content` and `summary` may be arrays, `null`, or absent, and each array part must be an object with a string `type`; its `text` may be a string, `null`, or absent, except `output_text`, `summary_text`, and `reasoning_text` parts require string `text`, and a `refusal` part requires string `refusal`. A `function_call` item requires string `name`, `call_id`, and `arguments`. A `custom_tool_call` item requires string `name`, `call_id`, and free-text `input`; its optional `id` and `status` must be strings when present. Unused fields, later chat choices, semantic relationships, and the contents of argument strings or objects are not checked. Empty answers are valid.

POST tools default to `split_thinking: true`: an orphan closing `</think>` splits preceding text into reasoning, then paired `<think>...</think>` blocks are removed from the answer and joined into reasoning. `split_thinking: false` keeps inline text in the answer; separate Pico reasoning fields are still used. Responses reasoning items use nonempty `summary`, otherwise `content`. `reasoning_effort` is passed as `reasoning_effort` in chat and `reasoning.effort` in Responses. Ollama `think` accepts a boolean. `extra.think` sends a level string or null unchanged; the tested Pico server accepted null and rejected level strings. In testing, Claude Desktop chat (with Sonnet 5.5 and Opus 5.5) sent `true` as the string `"true"` when `think` also accepted strings, while Claude Code sent a boolean. Comparison's `reasoning` applies to chat (`reasoning_effort`) and Responses (`reasoning.effort`); `think` and `options` apply only to Ollama. Using an API's inapplicable argument returns `invalid_arguments`. In an Ollama comparison, `options.num_predict` conflicts with `max_tokens`, and `options.temperature` conflicts with `temperature`; these also return `invalid_arguments`.

In the tested Pico server, replaying an Ollama tool call needed an assistant message with `tool_calls` items containing an `id`, `type: "function"`, and string `function.arguments`, followed by a tool message with a matching `tool_call_id`. That server rejected replays of its own id-less calls. For Chat Completions, the bridge parses valid JSON `tool_calls[].arguments` into a JSON value; replay the assistant message with `function.arguments` as a JSON string and add a matching tool message, for example `messages: [{role: "assistant", tool_calls: [{id: "call_1", type: "function", function: {name: "get_weather", arguments: "{\"city\":\"Tokyo\"}"}}]}, {role: "tool", tool_call_id: "call_1", content: "{\"temperature_c\":20}"}]`. For Responses, replaying the user item, the `function_call` item, and a `function_call_output` item worked; sending only `function_call_output` with `previous_response_id` was rejected. The tested server returned HTTP 400 with ``Coding key `function` not found.`` for Chat custom tools; Responses accepted the custom tool definitions but returned message output without custom calls. The tested server also returned HTTP 400 for an Ollama `format` JSON schema object and for Chat function tool definitions without `description`, although the Ollama and OpenAI API references document both as valid. On the tested server, Ollama `think: true` returned no `thinking` text and no inline `<think>` text for any tested model, while `LFM2.5-1.2B-Thinking-4bit` with `think: false` wrote inline `<think>` text that `split_thinking` moves into `reasoning`. Other servers may behave differently.

Bridge errors use `{source: "bridge", kind, message}`; Pico HTTP errors keep their status and parsed body. The configured API key is redacted after extraction in Pico-derived values and in keys of Pico objects returned as-is, including raw and error bodies, usage, Responses `error` and `incomplete_details`, tool-call arguments, model fields, and `api_show` keys. Bridge-built field names stay fixed. The bridge does not follow redirects. `pico_server_info` includes `bundle_identifier` and returns `null` for absent name, version, build, or bundle identifier and does not infer API support from the version; a successful non-object `/api/version` body returns `unexpected_response`.

## Install

### Claude Code marketplace

```text
/plugin marketplace add takuomi/pico-ai-bridge
/plugin install pico-ai-bridge@pico-ai-bridge
```

### Plugin zip

Download `pico-ai-bridge-<version>-plugin.zip` from [Releases](https://github.com/takuomi/pico-ai-bridge/releases), then open **Customize > Plugins > Upload plugin**. When Claude Code v2.1.273 or later is signed in with the same Claude account, the plugin syncs to it each time Claude Code starts; it does not sync when Claude Code uses an API key or a cloud provider such as Amazon Bedrock. Uploading does not make its local server available in claude.ai web chat.

### Claude Desktop extension

Download `pico-ai-bridge-<version>.mcpb` from Releases and double-click it, or use **Settings > Extensions > Advanced settings > Install Extension**.

### Manual MCP configuration

For Claude Code:

```sh
claude mcp add pico-ai-bridge --env PICO_BASE_URL=http://localhost:11434 -- node /absolute/path/to/pico-ai-bridge/server/index.js
```

For Claude Desktop, add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "pico-ai-bridge": {
      "command": "node",
      "args": ["/absolute/path/to/pico-ai-bridge/server/index.js"],
      "env": { "PICO_BASE_URL": "http://localhost:11434" }
    }
  }
}
```

## Surface support

| Surface | Support |
| --- | --- |
| Claude Desktop chat | Yes through `.mcpb`, using built-in Node.js. |
| Claude Code CLI and desktop Code tab | Yes through plugin or manual config; Node.js 20+ required. |
| Cowork | Not yet supported. |
| claude.ai web and mobile chat | Not supported; local MCP servers are not loaded there. |

## Configuration

| Plugin option / environment variable | Default | Meaning |
| --- | --- | --- |
| `base_url` / `PICO_BASE_URL` | Claude Desktop extension: required at install (no default). Claude Code plugin: `http://localhost:11434`. | Pico server URL. Do not include credentials in the URL; use API key. |
| `api_key` / `PICO_API_KEY` | empty | Optional Bearer token for authentication. |
| `timeout_ms` / `PICO_TIMEOUT_MS` | `300000` | Caller timeout for the request and body reading; integer range: 1 through 2147483647 ms. |
| `max_concurrent_requests` / `PICO_MAX_CONCURRENT_REQUESTS` | `1` | FIFO limit on HTTP requests to Pico. Loading several large models concurrently can exhaust a Mac's memory. |

A queued request that times out is removed without being sent to Pico. An individual request returns its timeout error promptly. A timeout after sending leaves the request holding its FIFO permit until Pico finishes and its body is read; a comparison waits for that request to finish before starting the next model, so the comparison may return later than the individual timeout. MCP cancellation ends the comparison wait promptly; cancellation removes queued requests only. Requests already sent are never aborted. Closing a connection while Pico was generating was followed by the server stopping on the next generation request in testing.

See [Privacy Policy](https://github.com/takuomi/pico-ai-bridge/blob/main/PRIVACY.md).

A missing, empty, or unsubstituted `PICO_BASE_URL` is rejected on every tool call with `invalid_configuration`. Configure `base_url` in the extension or plugin, or set `PICO_BASE_URL` for manual use.

A malformed `base_url`, one using a protocol other than HTTP or HTTPS, or one containing credentials, a query, or a fragment is rejected on every tool call with `invalid_configuration`. A timeout outside its integer range or a concurrency limit that is not a safe integer of at least 1 also returns `invalid_configuration`; empty or unsubstituted values use the defaults.

## Development

`npm test` runs the sorted `test/*.test.js` files, and `npm run test:conformance` runs the sorted `dev/*.test.js` files. Both commands fail if their directory has no listed files. `test/tools-contract.json` records the six public tool definitions checked by the unit and adapter conformance tests. `npm run test:live` runs `test/live.js` against `PICO_BASE_URL`. In CI, `scripts/smoke-artifacts.py` extracts each archive, substitutes the manifest's declared command, arguments, and environment values, then runs `initialize` and `pico_server_info` against a local mock Pico on Linux, macOS, and Windows. It does not install either archive in Claude Desktop or Claude Code.

```sh
npm test
npm ci --prefix dev
npm run test:conformance
npm run check:size
PICO_BASE_URL=http://localhost:11434 npm run test:live
```

Live tests use `PICO_TEST_TOOLS_MODEL` and `PICO_TEST_VISION_MODEL` (both default to `Qwen3-VL-2B-Instruct-4bit`) and `PICO_TEST_THINKING_MODELS` (a comma-separated list, default `LFM2.5-1.2B-Thinking-4bit`). `deepseek-r1-distill-qwen-1.5b-4bit` is not a default because on the tested server its `/api/chat` replies sometimes ran for about two minutes and ended without a `message`. Other servers may behave differently. At least two distinct role models are required. All distinct models run generation through the three APIs in default and raw modes with temperature 0 and a 4096-token limit. The live test also starts the shipped `server/index.js` stdio server, sends a legacy `initialize` and calls each of the six tools with integer IDs, including default and raw generation through all three APIs, checks each result, and checks for exit code 0 after stdin closes. It also uses 2026-07-28 per-request metadata without `initialize` to discover the server, list all six tools, and call each of the three generation tools through the shipped stdio server. Comparisons run sequentially with temperature 0 and a 3072-token limit (default and raw modes for all three APIs). Additional default comparisons for all three APIs use `messages: [{role: "user", content: prompt}]` with temperature 0 and a 1024-token limit; every row must have an answer and no error. A default chat comparison attaches the generated red PNG to the vision model and tools model (or repeats the vision model when they match) with the same settings and row checks. Temperature 0 keeps small thinking models from sometimes running away. The live test also checks GET tools, model merge and capabilities, tool calls and tool-result round trips, JSON formats, system instructions, `extra`, `keep_alive`, `previous_response_id`, and image content shapes. Structured-output formats, `keep_alive`, and `previous_response_id` are live acceptance checks: they show Pico accepted the request, while unit tests assert the exact outgoing fields. Tool-call, vision, and thinking behavior is asserted directly on the assigned role model without capability gating or retries. Vision uses a temporary generated red PNG. Prompted calls expect the answer to name red; image-only calls require a nonempty answer. Responses `reasoning_effort` is checked with `low`, which must succeed, and with an invalid value, which must return Pico HTTP 400, showing that the value reaches Pico as `reasoning.effort`. Thinking tests use Ollama `think:true` and `think:false` for each thinking model. For `think:false`, the assertions check bridge invariants on real output: no complete `<think>...</think>` pair remains in the answer when splitting, and `split_thinking: false` leaves the answer unsplit with empty reasoning. The tests do not require the model to write inline reasoning. An unknown model must return Pico HTTP 404 through all three APIs, with identical error data in raw and default modes and a body matching the direct Pico response. `PICO_TEST_BROKEN_MODEL` optionally selects another model expected to return a Pico error; all three APIs are checked in raw and default modes against a direct Pico HTTP response. A default chat comparison then checks that this error stays in its row and the tools model succeeds. A separate comparison uses a 1000 ms timeout and concurrency of 2. A local forwarding proxy records chat request arrival and response completion times and checks that the next model request arrives after the timed-out response finishes. The test then checks server information and a short chat.

SDK v2 dependencies and conformance tests live only in `dev/`. The same official v2 client assertions run against the shipped adapter and the SDK adapter in legacy, modern-pinned, and auto-detect modes. Modern clients use 2026-07-28 per-request metadata; `server/discover` and modern `-32022` errors list only `2026-07-28` for subsequent requests. Legacy clients use `initialize` to negotiate one of the four legacy revisions. Each request uses its own `_meta` to select modern handling. A request whose `params._meta` is present but not an object receives `-32602` in either era. A legacy request without modern metadata needs a successful legacy `initialize` on the connection, except `ping`; legacy `initialize` remains available after modern requests. Request IDs must be strings or safe integers; invalid IDs receive `-32600` with a null ID. Modern MCP removed `ping`; the conformance suite verifies it only in legacy mode. After a successful stdio initialize for 2025-03-26 or 2024-11-05, a nonempty JSON-RPC batch gets one response array if any element needs a reply, omitting notifications and cancelled requests; an all-notification or all-cancelled batch writes nothing, and an empty batch gets one `-32600` error object; an initialize inside a batch gets `-32600` for that element, and each modern-era request element gets its own `-32600` (modern notifications get no reply). Array lines remain invalid before initialize, with newer legacy revisions, and for modern per-request handling. See [Architecture](https://github.com/takuomi/pico-ai-bridge/blob/main/ARCHITECTURE.md).

Pico AI Server and its name are trademarks of their respective owner. This project is independent and MIT licensed.