Skip to content

Commit 405e4a4

Browse files
committed
cocalc-api/mcp: jupyter exec and more test fixes
1 parent 5434d8d commit 405e4a4

File tree

11 files changed

+285
-38
lines changed

11 files changed

+285
-38
lines changed

src/cocalc.code-workspace

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
{
44
"name": "cocalc",
55
"path": "."
6+
},
7+
{
8+
"path": "../.github"
69
}
710
],
811
"settings": {

src/packages/server/api/project-bridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { projectSubject } from "@cocalc/conat/names";
44
import { conat } from "@cocalc/backend/conat";
55
import { type Client as ConatClient } from "@cocalc/conat/core/client";
6+
import { getProject } from "@cocalc/server/projects/control";
67
const DEFAULT_TIMEOUT = 15000;
78

89
let client: ConatClient | null = null;
@@ -51,6 +52,12 @@ async function callProject({
5152
service: "api",
5253
});
5354
try {
55+
// Ensure the project is running before making the API call
56+
const project = getProject(project_id);
57+
if (project) {
58+
await project.start();
59+
}
60+
5461
// For system.test(), inject project_id into args[0] if not already present
5562
let finalArgs = args;
5663
if (name === "system.test" && (!args || args.length === 0)) {

src/python/cocalc-api/src/cocalc_api/mcp/DEVELOPMENT.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ The MCP server exposes high-level metadata that clients can query to understand
123123
**Protocol Version** - MCP 2025-06-18
124124

125125
Clients can retrieve this metadata using the MCP `initialize` call, which returns:
126+
126127
- `serverInfo.name` - "cocalc-api"
127128
- `serverInfo.version` - Version from package metadata
128129
- `instructions` - High-level guide (see above)
@@ -192,6 +193,41 @@ Execute arbitrary shell commands in the CoCalc project.
192193
{"command": "echo 'Hello'"}
193194
{"command": "python", "args": ["script.py", "--verbose"]}
194195
{"command": "for i in {1..3}; do echo $i; done", "bash": true}
196+
{"command": "jupyter kernelspec list"}
197+
{"command": "python3", "args": ["-m", "pip", "install", "--user", "ipykernel"]}
198+
```
199+
200+
#### `jupyter_execute` - Execute Code in Jupyter Kernels
201+
202+
Execute code using Jupyter kernels with rich output, preserved state, and support for multiple languages.
203+
204+
**Parameters:**
205+
206+
- `input` (string, required): Code to execute
207+
- `kernel` (string, optional): Kernel name (default: "python3")
208+
- Common kernels: `python3`, `ir` (R), `julia-1.9`
209+
- Use `exec` tool with `jupyter kernelspec list` to discover available kernels
210+
- `history` (list, optional): Previous code inputs to establish context
211+
- Executed without capturing output, allows setting up variables and imports
212+
213+
**Returns:** Formatted execution output (text, plots, dataframes, images, errors, etc.)
214+
215+
**Examples:**
216+
217+
```json
218+
{"input": "2 + 2", "kernel": "python3"}
219+
{"input": "import pandas as pd\ndf = pd.DataFrame({'a': [1,2,3]})\ndf", "kernel": "python3"}
220+
{"input": "import matplotlib.pyplot as plt\nplt.plot([1,2,3])\nplt.show()", "kernel": "python3"}
221+
{"input": "summary(cars)", "kernel": "ir"}
222+
```
223+
224+
**Setup Instructions:**
225+
226+
Before using Jupyter kernels, set them up with the `exec` tool:
227+
228+
```json
229+
{"command": "python3", "args": ["-m", "pip", "install", "--user", "ipykernel"], "timeout": 300}
230+
{"command": "python3", "args": ["-m", "ipykernel", "install", "--user", "--name=python3", "--display-name=Python 3"], "timeout": 120}
195231
```
196232

197233
### Resources

src/python/cocalc-api/src/cocalc_api/mcp/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ claude mcp add-json cocalc '{
5555
```
5656

5757
**Important:**
58+
5859
- Replace `/path/to/cocalc/src/python/cocalc-api` with the absolute path to your cocalc-api directory.
5960
- Replace `http://localhost:5000` with your CoCalc instance URL (defaults to `https://cocalc.com` if not set).
6061

@@ -67,7 +68,12 @@ Add to `~/.config/Claude/claude_desktop_config.json`:
6768
"mcpServers": {
6869
"cocalc": {
6970
"command": "uv",
70-
"args": ["--directory", "/path/to/cocalc/src/python/cocalc-api", "run", "cocalc-mcp-server"],
71+
"args": [
72+
"--directory",
73+
"/path/to/cocalc/src/python/cocalc-api",
74+
"run",
75+
"cocalc-mcp-server"
76+
],
7177
"env": {
7278
"COCALC_API_KEY": "sk-your-api-key-here",
7379
"COCALC_PROJECT_ID": "[UUID]",
@@ -79,6 +85,7 @@ Add to `~/.config/Claude/claude_desktop_config.json`:
7985
```
8086

8187
**Important:**
88+
8289
- Replace `/path/to/cocalc/src/python/cocalc-api` with the absolute path to your cocalc-api directory.
8390
- Replace `http://localhost:5000` with your CoCalc instance URL (defaults to `https://cocalc.com` if not set).
8491

@@ -88,13 +95,12 @@ To automatically allow all CoCalc MCP tools without prompts, add this to `.claud
8895

8996
```json
9097
{
91-
"allowedTools": [
92-
"mcp__cocalc__*"
93-
]
98+
"allowedTools": ["mcp__cocalc__*"]
9499
}
95100
```
96101

97102
This wildcard pattern (`mcp__cocalc__*`) automatically allows:
103+
98104
- `mcp__cocalc__exec` - Execute shell commands
99105
- `mcp__cocalc__project_files` - Browse project files
100106
- Any future tools added to the MCP server
@@ -104,12 +110,20 @@ This wildcard pattern (`mcp__cocalc__*`) automatically allows:
104110
### Tools
105111

106112
- **`exec`** - Execute shell commands in the project
113+
107114
```
108115
Tool: exec
109116
Params: command (required), args, bash, timeout, cwd
110117
Returns: {stdout, stderr, exit_code}
111118
```
112119

120+
- **`jupyter_execute`** - Execute code using Jupyter kernels
121+
```
122+
Tool: jupyter_execute
123+
Params: input (required), kernel (default: "python3"), history
124+
Returns: Formatted execution output (text, plots, errors, etc.)
125+
```
126+
113127
### Resources
114128

115129
- **`project-files`** - Browse project files with filtering and pagination
@@ -121,6 +135,7 @@ This wildcard pattern (`mcp__cocalc__*`) automatically allows:
121135
## Documentation
122136

123137
See [DEVELOPMENT.md](./DEVELOPMENT.md) for:
138+
124139
- Architecture and design principles
125140
- Detailed API specifications
126141
- Configuration options

src/python/cocalc-api/src/cocalc_api/mcp/mcp_debug.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
"""
2020

2121
import asyncio
22-
import json
2322
import os
2423
import sys
2524

src/python/cocalc-api/src/cocalc_api/mcp/mcp_server.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
AVAILABLE TOOLS (actions you can perform):
77
- exec: Run shell commands, scripts, and programs in the project
88
Use for: running code, data processing, build/test commands, git operations, etc.
9+
- jupyter_execute: Execute code using Jupyter kernels (Python, R, Julia, etc.)
10+
Use for: interactive code execution, data analysis, visualization, scientific computing
911
1012
AVAILABLE RESOURCES (information you can read):
1113
- project-files: Browse the project file structure
@@ -33,13 +35,40 @@
3335

3436
import os
3537
import sys
38+
import time
3639
from typing import Optional
3740

3841
from mcp.server.fastmcp import FastMCP
3942

4043
from cocalc_api import Project, Hub
4144

4245

46+
def _retry_with_backoff(func, max_retries: int = 3, retry_delay: int = 2):
47+
"""
48+
Retry a function with exponential backoff for transient failures.
49+
50+
Used during server initialization for operations that may timeout on cold starts.
51+
"""
52+
for attempt in range(max_retries):
53+
try:
54+
return func()
55+
except Exception as e:
56+
error_msg = str(e).lower()
57+
is_retryable = any(
58+
keyword in error_msg
59+
for keyword in ["timeout", "closed", "connection", "reset", "broken"]
60+
)
61+
if is_retryable and attempt < max_retries - 1:
62+
print(
63+
f"Initialization attempt {attempt + 1} failed ({error_msg[:50]}...), "
64+
f"retrying in {retry_delay}s...",
65+
file=sys.stderr,
66+
)
67+
time.sleep(retry_delay)
68+
else:
69+
raise
70+
71+
4372
def get_config() -> tuple[str, str, Optional[str]]:
4473
"""
4574
Get and validate MCP server configuration.
@@ -106,6 +135,7 @@ def check_api_key_scope(api_key: str, host: str) -> dict[str, str]:
106135
This server gives you direct access to a CoCalc project or account environment through the Model Context Protocol (MCP).
107136
108137
WHAT YOU CAN DO:
138+
- Execute code using Jupyter kernels (Python, R, Julia, etc.) with rich output and visualization support
109139
- Execute arbitrary shell commands in a Linux environment with Python, Node.js, R, Julia, and 100+ tools
110140
- Browse and explore project files to understand structure and contents
111141
- Run code, scripts, and build/test commands
@@ -114,11 +144,14 @@ def check_api_key_scope(api_key: str, host: str) -> dict[str, str]:
114144
115145
HOW TO USE:
116146
1. Start by exploring the project structure using the project-files resource
117-
2. Use the exec tool to run commands, scripts, or programs
118-
3. Combine multiple commands to accomplish complex workflows
147+
2. Use jupyter_execute for interactive code execution with rich output (plots, tables, etc.)
148+
3. Use exec tool to run shell commands, scripts, or programs
149+
4. Combine multiple commands to accomplish complex workflows
119150
120151
EXAMPLES:
121-
- Execute Python: exec with command="python3 script.py --verbose"
152+
- Execute Python interactively: jupyter_execute with input="import pandas as pd; df = pd.read_csv('data.csv'); df.describe()"
153+
- Data visualization: jupyter_execute with input="import matplotlib.pyplot as plt; plt.plot([1,2,3]); plt.show()"
154+
- Execute shell command: exec with command="python3 script.py --verbose"
122155
- List files: use project-files resource or exec with command="ls -la"
123156
- Run tests: exec with command="pytest tests/" bash=true
124157
- Git operations: exec with command="git log --oneline" in your repository
@@ -157,22 +190,12 @@ def _initialize_config() -> None:
157190
try:
158191
_api_key_scope = check_api_key_scope(_api_key, _host)
159192
except RuntimeError as check_error:
160-
# If it's a project-scoped key error, try the project API to discover the project_id
193+
# If it's a project-scoped key error, use a placeholder project_id
194+
# Project-scoped keys have the project_id embedded in the key itself
161195
if "project-scoped" in str(check_error):
162-
try:
163-
# Try with empty project_id - project-scoped keys will use their own
164-
project = Project(api_key=_api_key, project_id="", host=_host)
165-
result = project.system.ping()
166-
# Check if the response includes project_id (it shouldn't from ping, but try anyway)
167-
if isinstance(result, dict) and "project_id" in result:
168-
_api_key_scope = {"project_id": result["project_id"]}
169-
else:
170-
# If we still don't have it, this is an error
171-
raise RuntimeError("Could not determine project_id from project-scoped API key. "
172-
"Please restart with COCALC_PROJECT_ID environment variable.")
173-
except Exception as project_error:
174-
raise RuntimeError(f"Project-scoped API key detected but could not determine project_id. "
175-
f"Error: {project_error}") from project_error
196+
# Use empty string as project_id - the Project client will extract it from the API key
197+
_api_key_scope = {"project_id": ""}
198+
print("✓ Connected with project-scoped API key", file=sys.stderr)
176199
else:
177200
raise
178201

@@ -181,12 +204,15 @@ def _initialize_config() -> None:
181204
print(f"✓ Connected with account-scoped API key (account: {account_id})", file=sys.stderr)
182205
elif "project_id" in _api_key_scope:
183206
project_id = _api_key_scope["project_id"]
184-
if not project_id:
185-
raise RuntimeError("Project ID not found for project-scoped API key")
186-
print(f"✓ Connected with project-scoped API key (project: {project_id})", file=sys.stderr)
187-
# For project-scoped keys, eagerly create the project client
188-
client = Project(api_key=_api_key, project_id=project_id, host=_host)
189-
_project_clients[project_id] = client
207+
# For project-scoped keys with empty/None project_id, the Project client will extract it from the API key
208+
if project_id:
209+
print(f"✓ Connected with project-scoped API key (project: {project_id})", file=sys.stderr)
210+
# For project-scoped keys, eagerly create the project client
211+
client = Project(api_key=_api_key, project_id=project_id, host=_host)
212+
_project_clients[project_id] = client
213+
else:
214+
# Project-scoped key with empty project_id - will be discovered on first use
215+
print("✓ Connected with project-scoped API key (project ID will be discovered on first use)", file=sys.stderr)
190216
else:
191217
# If we got here with no project_id but it might be project-scoped, check if COCALC_PROJECT_ID was provided
192218
if project_id_config:
@@ -232,19 +258,24 @@ def get_project_client(project_id: Optional[str] = None) -> Project:
232258
raise RuntimeError("Account-scoped API key requires an explicit project_id argument. "
233259
"No project_id provided to get_project_client().")
234260

235-
if not project_id:
236-
raise RuntimeError("Project ID cannot be empty")
261+
# For project-scoped keys with None/empty project_id, the Project client will extract it from the API key
262+
# For account-scoped keys, project_id must be non-empty
263+
if not project_id and _api_key_scope and "account_id" in _api_key_scope:
264+
raise RuntimeError("Account-scoped API key requires a non-empty project_id")
265+
266+
# Use a cache key that handles None/empty project_id for project-scoped keys
267+
cache_key = project_id if project_id else "_default_project"
237268

238269
# Return cached client if available
239-
if project_id in _project_clients:
240-
return _project_clients[project_id]
270+
if cache_key in _project_clients:
271+
return _project_clients[cache_key]
241272

242273
# Create new project client
243274
# At this point, _api_key and _host are guaranteed to be non-None (set in _initialize_config)
244275
assert _api_key is not None
245276
assert _host is not None
246277
client = Project(api_key=_api_key, project_id=project_id, host=_host)
247-
_project_clients[project_id] = client
278+
_project_clients[cache_key] = client
248279
return client
249280

250281

src/python/cocalc-api/src/cocalc_api/mcp/tools/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
Available Tools:
77
- exec: Execute shell commands, scripts, and programs in the project environment
8+
- jupyter_execute: Execute code using Jupyter kernels with rich output and interactive state
89
910
See mcp_server.py for overview of all available tools and resources, and guidance
1011
on when to use each one.
@@ -14,5 +15,7 @@
1415
def register_tools(mcp) -> None:
1516
"""Register all tools with the given FastMCP instance."""
1617
from .exec import register_exec_tool
18+
from .jupyter import register_jupyter_tool
1719

1820
register_exec_tool(mcp)
21+
register_jupyter_tool(mcp)

src/python/cocalc-api/src/cocalc_api/mcp/tools/exec.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ async def exec(
3737
The command executes in the project's Linux shell environment with access to the
3838
project's file system and all installed packages/tools.
3939
40+
Common use cases:
41+
- List available Jupyter kernels: exec(command="jupyter kernelspec list")
42+
- Install ipykernel: exec(command="python3", args=["-m", "pip", "install", "--user", "ipykernel"])
43+
- Register Python kernel: exec(command="python3", args=["-m", "ipykernel", "install", "--user", "--name=python3", "--display-name=Python 3"])
44+
- Install packages: exec(command="pip", args=["install", "pandas"], bash=False)
45+
- Run scripts: exec(command="python", args=["script.py"])
46+
- Execute complex pipelines: exec(command="cat data.txt | grep pattern | wc -l", bash=True)
47+
4048
Args:
4149
command: The command to execute (e.g., 'ls -la', 'python script.py', 'echo 2 + 3 | bc')
4250
args: Optional list of arguments to pass to the command

0 commit comments

Comments
 (0)