Skip to content
Open
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
21 changes: 7 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -921,20 +921,13 @@ ran on railway gha runners on [pr](https://github.com/r33drichards/mcp-js/pull/3

## P95 Latency

| Topology | Rate | P95 (ms) | |
|----------|------|----------|-|
| cluster-stateful | 100/s | 196.88 | `█████████████████████` |
| cluster-stateful | 200/s | 79.32 | `█████████████████` |
| cluster-stateless | 100/s | 5.65 | `███████` |
| cluster-stateless | 200/s | 5.9 | `███████` |
| cluster-stateless | 500/s | 5.85 | `███████` |
| cluster-stateless | 1000/s | 7.72 | `████████` |
| single-stateful | 100/s | 362.5 | `███████████████████████` |
| single-stateful | 200/s | 2212.55 | `██████████████████████████████` |
| single-stateless | 100/s | 5.73 | `███████` |
| single-stateless | 200/s | 5.43 | `██████` |
| single-stateless | 500/s | 8.49 | `████████` |
| single-stateless | 1000/s | 482.98 | `████████████████████████` |
```mermaid
xychart-beta horizontal
title "P95 Latency by Topology and Rate (ms)"
x-axis ["cluster-stateful 100/s", "cluster-stateful 200/s", "cluster-stateless 100/s", "cluster-stateless 200/s", "cluster-stateless 500/s", "cluster-stateless 1000/s", "single-stateful 100/s", "single-stateful 200/s", "single-stateless 100/s", "single-stateless 200/s", "single-stateless 500/s", "single-stateless 1000/s"]
y-axis "P95 Latency (ms)" 0 --> 2300
bar [196.88, 79.32, 5.65, 5.9, 5.85, 7.72, 362.5, 2212.55, 5.73, 5.43, 8.49, 482.98]
```

## Notes

Expand Down
168 changes: 61 additions & 107 deletions tutorials/programmatic-tool-calling.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ mcp-v8 can connect to external MCP servers at startup and expose their tools to

## How It Works

```
AI Agent ←→ [1 tool: run_js] ←→ mcp-v8 ←→ External MCP Servers
V8 sandbox with
mcp.callTool() API
```mermaid
flowchart LR
A[AI Agent] <--> B["1 tool: run_js"]
B <--> C[mcp-v8]
C <--> D[External MCP Servers]
E["V8 sandbox\nmcp.callTool() API"] --> C
```

The `--mcp-server` flag tells mcp-v8 to connect to an external MCP server and make its tools available inside the JavaScript runtime via a `globalThis.mcp` object:
Expand All @@ -21,19 +22,19 @@ await mcp.callTool("server", "tool", { ... }) // call a tool and get results

The AI model sees only `run_js`. It writes JavaScript that discovers and calls external tools programmatically — no extra round trips to the model needed.

## Case Study: Constraint Solving with MiniZinc
## Case Study: Building a Constraint-Solving Agent

This walkthrough connects mcp-v8 to the [MiniZinc MCP server](https://github.com/r33drichards/minizinc-mcp), a constraint solver that exposes a single `solve_constraint` tool. We solve three problems of increasing complexity entirely from JavaScript inside the sandbox.
This walkthrough builds a [PydanticAI](https://ai.pydantic.dev/) agent that connects to mcp-v8, which proxies the [MiniZinc MCP server](https://github.com/r33drichards/minizinc-mcp)a constraint solver exposing a single `solve_constraint` tool. The agent solves three problems of increasing complexity by writing JavaScript that calls the solver through the V8 sandbox.

### Prerequisites

- mcp-v8 installed ([install instructions](../README.md#installation))
- MiniZinc MCP server running locally or hosted
- Python 3.11+ with `uv`
- `ANTHROPIC_API_KEY` set (or configure a different [PydanticAI model](https://ai.pydantic.dev/models/))

### Step 1: Start the MiniZinc MCP Server

If running locally:

```bash
git clone https://github.com/r33drichards/minizinc-mcp
cd minizinc-mcp
Expand All @@ -59,132 +60,85 @@ All MCP servers connected. JS code can use mcp.callTool(), mcp.listTools(), mcp.
Streamable HTTP server listening on 0.0.0.0:3000
```

### Step 3: Discover Available Tools

List connected servers and their tools from JavaScript:

```bash
curl -s -X POST http://localhost:3000/api/exec \
-H "Content-Type: application/json" \
-d '{"code": "console.log(JSON.stringify(mcp.servers))"}'
```

Output:

```json
["minizinc"]
```
### Step 3: Build the Agent

List tools with full schemas:
The full script is at [`tutorials/solve_with_agent.py`](solve_with_agent.py). The key parts:

```bash
curl -s -X POST http://localhost:3000/api/exec \
-H "Content-Type: application/json" \
-d '{"code": "console.log(JSON.stringify(mcp.listTools(\"minizinc\").map(t => ({ name: t.name, description: t.description.trim().split(String.fromCharCode(10))[0] }))))"}'
```
```python
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP

Output:
SYSTEM_PROMPT = (
"You are a constraint-solving assistant with access to run_js, "
"which executes JavaScript in a V8 sandbox connected to the MiniZinc MCP server:\n\n"
" mcp.servers\n"
" mcp.listTools('minizinc')\n"
" await mcp.callTool('minizinc', 'solve_constraint', { problem: { model: '...' } })\n\n"
"Write JavaScript to call the MiniZinc solver, then report the solution clearly."
)

```json
[{"name": "solve_constraint", "description": "Solve a constraint satisfaction or optimization problem."}]
server = MCPServerStreamableHTTP("http://localhost:3000/mcp")
agent = Agent("anthropic:claude-sonnet-4-6", system_prompt=SYSTEM_PROMPT, toolsets=[server])
```

### Step 4: Solve the N-Queens Problem
The agent connects to mcp-v8 via Streamable HTTP MCP. The model sees only `run_js` — it never knows about the MiniZinc server directly. When asked to solve a constraint problem, Claude writes JavaScript that calls `mcp.callTool("minizinc", ...)` inside the sandbox.

The classic N-Queens problem: place N queens on an N×N chessboard so no two queens threaten each other.
### Step 4: Run the Agent

```bash
curl -s -X POST http://localhost:3000/api/exec \
-H "Content-Type: application/json" \
-d '{
"code": "(async () => { const result = await mcp.callTool(\"minizinc\", \"solve_constraint\", { problem: { model: \"int: n = 4;\\narray[1..n] of var 1..n: queens;\\ninclude \\\"alldifferent.mzn\\\";\\nconstraint alldifferent(queens);\\nconstraint alldifferent(i in 1..n)(queens[i] + i);\\nconstraint alldifferent(i in 1..n)(queens[i] - i);\\nsolve satisfy;\" } }); console.log(JSON.stringify(result, null, 2)); })()"
}'
uv run tutorials/solve_with_agent.py
```

Result:

```json
{
"content": [{
"type": "text",
"text": "{\"solutions\":[{\"variables\":{\"queens\":[3,1,4,2]},\"objective\":null,\"is_optimal\":false}],\"status\":\"SATISFIED\",\"solve_time\":0.000204,\"num_solutions\":1,\"error\":null}"
}],
"isError": false
}
```
The script sends three prompts to the agent in sequence. For each one, Claude generates JavaScript, executes it via `run_js`, and reports the solution.

Queens placed at rows `[3, 1, 4, 2]` — a valid solution where no two queens share a row, column, or diagonal.
#### N-Queens (n=4)

### Step 5: Solve a Knapsack Optimization
Prompt: *"Place 4 queens on a 4×4 chessboard so no two threaten each other."*

A 0/1 knapsack problem: given items with weights and values, maximize total value without exceeding capacity.
Claude writes JavaScript that builds the MiniZinc model and calls the solver:

```bash
curl -s -X POST http://localhost:3000/api/exec \
-H "Content-Type: application/json" \
-d '{
"code": "(async () => { const model = \"int: n = 5;\\narray[1..n] of int: weight = [2, 3, 4, 5, 9];\\narray[1..n] of int: value = [3, 4, 8, 8, 10];\\nint: capacity = 20;\\narray[1..n] of var 0..1: x;\\nconstraint sum(i in 1..n)(weight[i] * x[i]) <= capacity;\\nsolve maximize sum(i in 1..n)(value[i] * x[i]);\"; const result = await mcp.callTool(\"minizinc\", \"solve_constraint\", { problem: { model } }); console.log(JSON.stringify(result, null, 2)); })()"
}'
```js
const result = await mcp.callTool("minizinc", "solve_constraint", {
problem: {
model: `
int: n = 4;
array[1..n] of var 1..n: queens;
include "alldifferent.mzn";
constraint alldifferent(queens);
constraint alldifferent(i in 1..n)(queens[i] + i);
constraint alldifferent(i in 1..n)(queens[i] - i);
solve satisfy;
`
}
});
console.log(JSON.stringify(result));
```

Result:

```json
{
"solutions": [{
"variables": {
"x": [1, 0, 1, 1, 1],
"objective": 29
},
"objective": 29.0,
"is_optimal": true
}],
"status": "OPTIMAL_SOLUTION",
"solve_time": 0.000387
}
```
Result: queens at rows `[3, 1, 4, 2]` — no two share a row, column, or diagonal.

The solver selects items 1, 3, 4, and 5 (total weight = 2+4+5+9 = 20, total value = 3+8+8+10 = 29) — provably optimal.
#### Knapsack Optimization

### Step 6: Solve a Graph Coloring Problem
Prompt: *"5 items with weights [2,3,4,5,9] and values [3,4,8,8,10], capacity=20. Maximize value."*

Color 5 nodes of a graph with at most 3 colors such that no two adjacent nodes share a color.
Claude constructs the MiniZinc maximize objective. Result: items 1, 3, 4, 5 selected (total weight = 20, total value = 29) — provably optimal.

```bash
curl -s -X POST http://localhost:3000/api/exec \
-H "Content-Type: application/json" \
-d '{
"code": "(async () => { const model = \"int: n = 5;\\nint: c = 3;\\narray[1..n] of var 1..c: color;\\nconstraint color[1] != color[2];\\nconstraint color[1] != color[3];\\nconstraint color[2] != color[3];\\nconstraint color[2] != color[4];\\nconstraint color[3] != color[4];\\nconstraint color[3] != color[5];\\nconstraint color[4] != color[5];\\nsolve satisfy;\"; const result = await mcp.callTool(\"minizinc\", \"solve_constraint\", { problem: { model } }); console.log(JSON.stringify(result, null, 2)); })()"
}'
```
#### Graph Coloring

Result:
Prompt: *"Color 5 nodes with at most 3 colors, no adjacent nodes sharing a color."*

```json
{
"solutions": [{
"variables": {
"color": [2, 3, 1, 2, 3]
}
}],
"status": "SATISFIED"
}
```
Claude encodes the edges as `!=` constraints. Result: `[2, 3, 1, 2, 3]` — a valid 3-coloring.

## Why Programmatic Tool Calling Matters

### Token Efficiency

When an AI agent connects directly to an MCP server with many tools, every tool schema is sent to the model on every turn. The [token comparison case study](token-comparison/README.md) measured this effect using the GitHub MCP server (26 tools):

```
Direct MCP mcp-v8 proxy
(26 tools) (1 tool)
─────────────────────────────────────────────────
Avg input tokens 121,450 114,763
Avg total tokens 122,056 117,826
vs. Direct — -3%
```
| Metric | Direct MCP (26 tools) | mcp-v8 proxy (1 tool) |
|--------|----------------------|----------------------|
| Avg input tokens | 121,450 | 114,763 |
| Avg total tokens | 122,056 | 117,826 |
| vs. Direct | — | -3% |

The savings increase with the number of tools exposed. With a single `run_js` tool, the model context stays small regardless of how many external tools are connected behind mcp-v8.

Expand All @@ -210,7 +164,7 @@ mcp-v8 --stateless --http-port 3000 \
--mcp-server 'github=stdio:npx:-y:@modelcontextprotocol/server-github'
```

JavaScript code can then call tools on any connected server:
The agent prompt can then ask Claude to call tools on any connected server:

```js
mcp.servers; // ["minizinc", "github"]
Expand All @@ -220,7 +174,7 @@ await mcp.callTool("github", "search_repositories", { query: "..." });

### OPA Policy Gating

When mcp-v8 is started with policy configuration, every `mcp.callTool()` invocation is evaluated against a Rego policy before the call is forwarded. This lets you restrict which tools can be called and with what arguments:
When mcp-v8 is started with policy configuration, every `mcp.callTool()` invocation is evaluated against a Rego policy before the call is forwarded. This lets you restrict which tools the agent can call and with what arguments:

```rego
package mcp.tools
Expand Down
88 changes: 88 additions & 0 deletions tutorials/solve_with_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pydantic-ai[mcp]",
# ]
# ///
"""
Constraint-solving agent using mcp-v8 + MiniZinc MCP server.

The agent connects to mcp-v8 via Streamable HTTP. mcp-v8 proxies the MiniZinc
MCP server, exposing it through a V8 sandbox. Claude writes JavaScript that
calls mcp.callTool("minizinc", "solve_constraint", ...) to solve each problem.

Usage:
# Start the MiniZinc MCP server:
git clone https://github.com/r33drichards/minizinc-mcp
cd minizinc-mcp && pip install -r requirements.txt && python main.py

# Start mcp-v8 with MiniZinc connected:
mcp-v8 --stateless --http-port 3000 \
--mcp-server 'minizinc=sse:http://localhost:8000/sse'

# Run the agent:
uv run tutorials/solve_with_agent.py
"""

import asyncio
import os

from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP

MCPJS_URL = os.environ.get("MCPJS_URL", "http://localhost:3000/mcp")
MODEL = os.environ.get("MODEL", "anthropic:claude-sonnet-4-6")

SYSTEM_PROMPT = (
"You are a constraint-solving assistant with access to run_js, "
"which executes JavaScript in a V8 sandbox connected to the MiniZinc MCP server:\n\n"
" mcp.servers // string[] of connected server names\n"
" mcp.listTools('minizinc') // list available tools with schemas\n"
" await mcp.callTool('minizinc', 'solve_constraint', { problem: { model: '...' } })\n\n"
"Write JavaScript to call the MiniZinc solver, then report the solution clearly."
)

PROBLEMS = [
(
"N-Queens (n=4)",
(
"Solve the 4-Queens problem: place 4 queens on a 4×4 chessboard "
"so no two queens threaten each other. Use the MiniZinc solver and "
"report which row each queen is placed in."
),
),
(
"Knapsack Optimization",
(
"Solve a 0/1 knapsack problem: 5 items with weights [2,3,4,5,9] and "
"values [3,4,8,8,10], capacity=20. Maximize total value. Use MiniZinc "
"and report which items to select and the total value achieved."
),
),
(
"Graph Coloring",
(
"Color a 5-node graph with at most 3 colors so no adjacent nodes share "
"a color. Edges: (1,2),(1,3),(2,3),(2,4),(3,4),(3,5),(4,5). Use MiniZinc "
"and report the color assigned to each node."
),
),
]


async def main():
server = MCPServerStreamableHTTP(MCPJS_URL)
agent = Agent(MODEL, system_prompt=SYSTEM_PROMPT, toolsets=[server])

async with agent:
for label, prompt in PROBLEMS:
print(f"\n{'='*60}")
print(f"Problem: {label}")
print(f"{'='*60}")
result = await agent.run(prompt)
print(result.output)


if __name__ == "__main__":
asyncio.run(main())
Loading