Skip to content

Commit 2ebd81e

Browse files
Move embedding computation from in-process ONNX to pgvector container
The ONNX runtime segfaults in GraalVM native images due to JNI incompatibility. Instead of fighting native-image compatibility, this moves the embedding model into a custom pgvector container that bundles a lightweight Python embedding server alongside PostgreSQL. The custom container (ghcr.io/quarkusio/quarkus-agent-pgvector:pg17) runs both PostgreSQL with pgvector on port 5432 and a sentence- transformers embedding service (BGE Small EN v1.5) on port 9222. The image is Quarkus-version-independent and built once. The MCP server now calls the container's HTTP endpoint to embed queries instead of loading the ONNX model in-process. This removes the langchain4j-embeddings, DJL, and ONNX runtime dependencies, shrinking the uber-jar from 184 MB to 41 MB and enabling doc search to work identically in both JVM and native mode.
1 parent 7bdb667 commit 2ebd81e

16 files changed

Lines changed: 344 additions & 258 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Build Container Image
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
paths:
7+
- 'container/**'
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
packages: write
13+
14+
jobs:
15+
build-and-push:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v6
19+
20+
- name: Log in to GHCR
21+
uses: docker/login-action@v3
22+
with:
23+
registry: ghcr.io
24+
username: ${{ github.actor }}
25+
password: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: Set up Docker Buildx
28+
uses: docker/setup-buildx-action@v3
29+
30+
- name: Build and push
31+
uses: docker/build-push-action@v6
32+
with:
33+
context: container
34+
push: true
35+
platforms: linux/amd64,linux/arm64
36+
tags: |
37+
ghcr.io/${{ github.repository_owner }}/quarkus-agent-pgvector:pg17
38+
cache-from: type=gha
39+
cache-to: type=gha,mode=max

README.md

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,29 +138,61 @@ Download the uber-jar from the [latest GitHub Release](https://github.com/quarku
138138
claude mcp add -s user quarkus-agent -- java -jar /path/to/quarkus-agent-mcp-runner.jar
139139
```
140140

141-
### Native binary
141+
### Native binary (Linux & macOS)
142142

143-
Pre-built native binaries are available on each [GitHub Release](https://github.com/quarkusio/quarkus-agent-mcp/releases/latest) for Linux and macOS. They start instantly and use ~100-200 MB of memory instead of 1+ GB on the JVM — ideal for VPS and resource-constrained environments.
143+
Pre-built native binaries are available on each [GitHub Release](https://github.com/quarkusio/quarkus-agent-mcp/releases/latest). They start instantly and use significantly less memory than the JVM version — ideal for VPS and resource-constrained environments. No JVM or JBang installation required.
144144

145-
| Platform | Artifact |
146-
|----------|----------|
147-
| Linux x86_64 | `quarkus-agent-mcp-<version>-linux-x86_64` |
148-
| macOS Intel | `quarkus-agent-mcp-<version>-macos-x86_64` |
149-
| macOS Apple Silicon | `quarkus-agent-mcp-<version>-macos-aarch64` |
145+
> **Note:** Windows is not supported for the native binary. Use the [JBang](#via-jbang-recommended) or [direct download](#via-direct-download) method instead.
146+
147+
#### Quick install (recommended)
148+
149+
Detects your platform, downloads the latest binary to `~/.local/bin/`, and registers it with Claude Code:
150+
151+
```bash
152+
curl -sL https://raw.githubusercontent.com/quarkusio/quarkus-agent-mcp/main/install.sh | bash
153+
```
154+
155+
To update to a newer version, run the same command again.
156+
157+
#### Manual install
158+
159+
##### Linux (x86_64)
160+
161+
```bash
162+
VERSION=$(curl -sI https://github.com/quarkusio/quarkus-agent-mcp/releases/latest | grep -i ^location: | sed 's|.*/||' | tr -d '\r')
163+
mkdir -p ~/.local/bin
164+
curl -L -o ~/.local/bin/quarkus-agent-mcp \
165+
"https://github.com/quarkusio/quarkus-agent-mcp/releases/download/${VERSION}/quarkus-agent-mcp-${VERSION}-linux-x86_64"
166+
chmod +x ~/.local/bin/quarkus-agent-mcp
167+
168+
claude mcp add -s user quarkus-agent -- ~/.local/bin/quarkus-agent-mcp
169+
```
170+
171+
##### macOS (Apple Silicon)
172+
173+
```bash
174+
VERSION=$(curl -sI https://github.com/quarkusio/quarkus-agent-mcp/releases/latest | grep -i ^location: | sed 's|.*/||' | tr -d '\r')
175+
mkdir -p ~/.local/bin
176+
curl -L -o ~/.local/bin/quarkus-agent-mcp \
177+
"https://github.com/quarkusio/quarkus-agent-mcp/releases/download/${VERSION}/quarkus-agent-mcp-${VERSION}-macos-aarch64"
178+
chmod +x ~/.local/bin/quarkus-agent-mcp
179+
180+
claude mcp add -s user quarkus-agent -- ~/.local/bin/quarkus-agent-mcp
181+
```
182+
183+
##### macOS (Intel)
150184

151185
```bash
152-
# Download the latest version (replace PLATFORM with your platform from the table above)
153-
PLATFORM=linux-x86_64
154186
VERSION=$(curl -sI https://github.com/quarkusio/quarkus-agent-mcp/releases/latest | grep -i ^location: | sed 's|.*/||' | tr -d '\r')
155-
curl -L -o quarkus-agent-mcp \
156-
"https://github.com/quarkusio/quarkus-agent-mcp/releases/download/${VERSION}/quarkus-agent-mcp-${VERSION}-${PLATFORM}"
157-
chmod +x quarkus-agent-mcp
187+
mkdir -p ~/.local/bin
188+
curl -L -o ~/.local/bin/quarkus-agent-mcp \
189+
"https://github.com/quarkusio/quarkus-agent-mcp/releases/download/${VERSION}/quarkus-agent-mcp-${VERSION}-macos-x86_64"
190+
chmod +x ~/.local/bin/quarkus-agent-mcp
158191

159-
# Register with Claude Code
160-
claude mcp add -s user quarkus-agent -- /path/to/quarkus-agent-mcp
192+
claude mcp add -s user quarkus-agent -- ~/.local/bin/quarkus-agent-mcp
161193
```
162194

163-
No JVM or JBang installation required.
195+
To update to a newer version, re-run the install script above or repeat these commands.
164196

165197
### Build from source
166198

container/Dockerfile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
FROM pgvector/pgvector:pg17 AS base
2+
3+
# --- Stage 1: export ONNX model (needs PyTorch, discarded after) ---
4+
FROM python:3.12-slim AS model-builder
5+
6+
RUN pip install --no-cache-dir optimum[onnxruntime] tokenizers
7+
8+
RUN python -c "\
9+
from optimum.onnxruntime import ORTModelForFeatureExtraction; \
10+
from tokenizers import Tokenizer; \
11+
model = ORTModelForFeatureExtraction.from_pretrained('BAAI/bge-small-en-v1.5', export=True); \
12+
model.save_pretrained('/opt/model'); \
13+
t = Tokenizer.from_pretrained('BAAI/bge-small-en-v1.5'); \
14+
t.save('/opt/model/tokenizer.json'); \
15+
print('Model exported')"
16+
17+
# --- Stage 2: final image (no PyTorch) ---
18+
FROM base
19+
20+
RUN apt-get update && \
21+
apt-get install -y --no-install-recommends python3 python3-pip && \
22+
rm -rf /var/lib/apt/lists/*
23+
24+
RUN pip3 install --no-cache-dir --break-system-packages \
25+
onnxruntime numpy tokenizers
26+
27+
COPY --from=model-builder /opt/model /opt/model
28+
COPY embed_server.py /opt/embed_server.py
29+
COPY start.sh /opt/start.sh
30+
RUN chmod +x /opt/start.sh
31+
32+
EXPOSE 5432 9222
33+
34+
ENTRYPOINT ["/opt/start.sh"]

container/embed_server.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import json
2+
import os
3+
import numpy as np
4+
import onnxruntime as ort
5+
from http.server import HTTPServer, BaseHTTPRequestHandler
6+
from tokenizers import Tokenizer
7+
8+
MODEL_DIR = os.environ.get("MODEL_DIR", "/opt/model")
9+
PORT = int(os.environ.get("EMBEDDING_PORT", "9222"))
10+
11+
print(f"Loading tokenizer from {MODEL_DIR}...", flush=True)
12+
tokenizer = Tokenizer.from_file(os.path.join(MODEL_DIR, "tokenizer.json"))
13+
tokenizer.enable_padding(length=512)
14+
tokenizer.enable_truncation(max_length=512)
15+
16+
print(f"Loading ONNX model from {MODEL_DIR}...", flush=True)
17+
session = ort.InferenceSession(
18+
os.path.join(MODEL_DIR, "model.onnx"),
19+
providers=["CPUExecutionProvider"]
20+
)
21+
print(f"Model loaded (ready to serve on port {PORT})", flush=True)
22+
23+
24+
class EmbedHandler(BaseHTTPRequestHandler):
25+
def do_POST(self):
26+
if self.path != "/embed":
27+
self._send(404, {"error": "not found"})
28+
return
29+
try:
30+
length = int(self.headers.get("Content-Length", 0))
31+
body = json.loads(self.rfile.read(length))
32+
text = body.get("text", "")
33+
if not text:
34+
self._send(400, {"error": "text is required"})
35+
return
36+
37+
encoded = tokenizer.encode(text)
38+
input_ids = np.array([encoded.ids], dtype=np.int64)
39+
attention_mask = np.array([encoded.attention_mask], dtype=np.int64)
40+
token_type_ids = np.zeros_like(input_ids)
41+
42+
outputs = session.run(None, {
43+
"input_ids": input_ids,
44+
"attention_mask": attention_mask,
45+
"token_type_ids": token_type_ids,
46+
})
47+
48+
cls_embedding = outputs[0][0][0].astype(float)
49+
norm = np.linalg.norm(cls_embedding)
50+
if norm > 0:
51+
cls_embedding = cls_embedding / norm
52+
53+
self._send(200, {"embedding": cls_embedding.tolist()})
54+
except Exception as e:
55+
self._send(500, {"error": str(e)})
56+
57+
def do_GET(self):
58+
if self.path == "/health":
59+
self._send(200, {"status": "ready"})
60+
else:
61+
self._send(404, {"error": "not found"})
62+
63+
def _send(self, code, obj):
64+
self.send_response(code)
65+
self.send_header("Content-Type", "application/json")
66+
self.end_headers()
67+
self.wfile.write(json.dumps(obj).encode())
68+
69+
def log_message(self, fmt, *args):
70+
pass
71+
72+
73+
if __name__ == "__main__":
74+
server = HTTPServer(("0.0.0.0", PORT), EmbedHandler)
75+
print(f"Embedding server listening on port {PORT}", flush=True)
76+
server.serve_forever()

container/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
onnxruntime>=1.18,<2.0
2+
numpy>=1.24,<3.0
3+
tokenizers>=0.19,<1.0

container/start.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/bin/bash
2+
set -e
3+
4+
python3 /opt/embed_server.py &
5+
EMBED_PID=$!
6+
7+
docker-entrypoint.sh postgres &
8+
PG_PID=$!
9+
10+
trap "kill $EMBED_PID $PG_PID 2>/dev/null; wait" SIGTERM SIGINT
11+
12+
wait -n $EMBED_PID $PG_PID
13+
EXIT_CODE=$?
14+
kill $EMBED_PID $PG_PID 2>/dev/null
15+
wait
16+
exit $EXIT_CODE

install.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/bash
2+
set -e
3+
4+
REPO="quarkusio/quarkus-agent-mcp"
5+
INSTALL_DIR="${HOME}/.local/bin"
6+
7+
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
8+
ARCH=$(uname -m)
9+
10+
case "${OS}" in
11+
linux) PLATFORM="linux-x86_64" ;;
12+
darwin)
13+
case "${ARCH}" in
14+
arm64|aarch64) PLATFORM="macos-aarch64" ;;
15+
x86_64) PLATFORM="macos-x86_64" ;;
16+
*) echo "Unsupported architecture: ${ARCH}"; exit 1 ;;
17+
esac
18+
;;
19+
*) echo "Unsupported OS: ${OS}. Use JBang instead: claude mcp add -s user quarkus-agent -- jbang quarkus-agent-mcp@quarkusio"; exit 1 ;;
20+
esac
21+
22+
VERSION=$(curl -sI "https://github.com/${REPO}/releases/latest" | grep -i ^location: | sed 's|.*/||' | tr -d '\r')
23+
if [ -z "${VERSION}" ]; then
24+
echo "Failed to determine latest version"
25+
exit 1
26+
fi
27+
28+
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/quarkus-agent-mcp-${VERSION}-${PLATFORM}"
29+
30+
echo "Installing quarkus-agent-mcp ${VERSION} (${PLATFORM})..."
31+
mkdir -p "${INSTALL_DIR}"
32+
curl -fL -o "${INSTALL_DIR}/quarkus-agent-mcp" "${DOWNLOAD_URL}"
33+
chmod +x "${INSTALL_DIR}/quarkus-agent-mcp"
34+
35+
if command -v claude &>/dev/null; then
36+
claude mcp remove quarkus-agent -s user 2>/dev/null || true
37+
claude mcp add -s user quarkus-agent -- "${INSTALL_DIR}/quarkus-agent-mcp"
38+
echo "Registered as MCP server with Claude Code"
39+
else
40+
echo "Claude Code not found — register manually:"
41+
echo " claude mcp add -s user quarkus-agent -- ${INSTALL_DIR}/quarkus-agent-mcp"
42+
fi
43+
44+
echo "Done! Installed to ${INSTALL_DIR}/quarkus-agent-mcp"

pom.xml

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
<langchain4j.version>1.12.2-beta22</langchain4j.version>
5555
<skipITs>true</skipITs>
5656
<surefire-plugin.version>3.5.4</surefire-plugin.version>
57-
<djl.version>0.28.0</djl.version>
5857
</properties>
5958

6059
<dependencyManagement>
@@ -66,18 +65,6 @@
6665
<type>pom</type>
6766
<scope>import</scope>
6867
</dependency>
69-
<!-- Pin DJL to 0.28.x: version 0.31.1 (pulled by langchain4j) fails to load
70-
the Huggingface tokenizer native library on macOS with "Unexpected flavor: cpu" -->
71-
<dependency>
72-
<groupId>ai.djl</groupId>
73-
<artifactId>api</artifactId>
74-
<version>${djl.version}</version>
75-
</dependency>
76-
<dependency>
77-
<groupId>ai.djl.huggingface</groupId>
78-
<artifactId>tokenizers</artifactId>
79-
<version>${djl.version}</version>
80-
</dependency>
8168
<!-- Quarkus BOM pins docker-java at 3.7.0, but Testcontainers 2.x needs 3.7.1
8269
which fixes Docker API version negotiation on Docker Engine 29+ (Windows) -->
8370
<dependency>
@@ -111,12 +98,6 @@
11198
<artifactId>langchain4j-pgvector</artifactId>
11299
<version>${langchain4j.version}</version>
113100
</dependency>
114-
<!-- BGE Small EN v1.5 embedding model - must match chappie-docling-rag -->
115-
<dependency>
116-
<groupId>dev.langchain4j</groupId>
117-
<artifactId>langchain4j-embeddings-bge-small-en-v15-q</artifactId>
118-
<version>${langchain4j.version}</version>
119-
</dependency>
120101
<dependency>
121102
<groupId>io.quarkus</groupId>
122103
<artifactId>quarkus-arc</artifactId>

src/main/java/io/quarkus/agent/mcp/ContainerManager.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ public String getHost(String quarkusVersion) {
172172
return container.getHost();
173173
}
174174

175+
public String getEmbeddingHost() {
176+
GenericContainer<?> container = getContainer(null);
177+
return container.getHost();
178+
}
179+
180+
public int getEmbeddingPort() {
181+
GenericContainer<?> container = getContainer(null);
182+
return container.getMappedPort(9222);
183+
}
184+
175185
private void checkDockerAvailable() {
176186
if (dockerAvailable == null) {
177187
try {
@@ -222,14 +232,17 @@ private void startGenericContainer(String versionKey) {
222232
LOG.infof("Starting pgvector container for Quarkus %s docs (%s)...", versionKey, image);
223233

224234
GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse(image))
225-
.withExposedPorts(5432)
235+
.withExposedPorts(5432, 9222)
226236
.withEnv("POSTGRES_USER", pgUser)
227237
.withEnv("POSTGRES_PASSWORD", pgPassword)
228238
.withEnv("POSTGRES_DB", pgDatabase)
229239
.withReuse(true)
230240
.withLabel("quarkus-agent-mcp", "doc-search")
231241
.withLabel("quarkus-agent-mcp.version", versionKey)
232-
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2));
242+
.waitingFor(new org.testcontainers.containers.wait.strategy.WaitAllStrategy()
243+
.withStrategy(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2))
244+
.withStrategy(Wait.forHttp("/health").forPort(9222).forStatusCode(200))
245+
.withStartupTimeout(java.time.Duration.ofMinutes(3)));
233246

234247
container.start();
235248
containers.put(versionKey, container);

0 commit comments

Comments
 (0)