Skip to content

Commit 7bffb75

Browse files
Add MCP config to the .jupyter directory (#1385)
* Adding basic MCP config loader. * Add caching to mcp config loading. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update MCP loader to use .jupyter. * Add MCP config loader tests. * Precommit fixes. * Cleanup bad rebase. * Precommit fixes. * Fix handling of None return value in get_mcp_config. * Precommit fixes. * Move MCP config to persona manager and address review comments. * Precommit fixes. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3ff5cf2 commit 7bffb75

File tree

6 files changed

+489
-1
lines changed

6 files changed

+489
-1
lines changed

packages/jupyter-ai/jupyter_ai/mcp/__init__.py

Whitespace-only changes.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import json
2+
from pathlib import Path
3+
from typing import Any
4+
5+
from jsonschema import ValidationError, validate
6+
from jsonschema.exceptions import SchemaError
7+
8+
SCHEMA_FILE = Path(__file__).parent / "schema.json"
9+
10+
11+
class MCPConfigLoader:
12+
"""Loader for MCP server configuration files with JSON schema validation."""
13+
14+
def __init__(self):
15+
# Load the schema from the schema.json file
16+
with open(SCHEMA_FILE) as f:
17+
self.schema = json.load(f)
18+
19+
# Cache for storing configurations and their modification times
20+
# Key: config_path (str), Value: (config_dict, last_modified_time)
21+
self._cache: dict[str, tuple[dict[str, Any], float]] = {}
22+
23+
def get_config(self, jupyter_dir: str) -> dict[str, Any]:
24+
"""
25+
Read and validate an MCP server configuration file from .jupyter/mcp/config.json.
26+
27+
Args:
28+
jupyter_dir (str): Path to the .jupyter directory
29+
30+
Returns:
31+
Dict[str, Any]: The validated configuration object
32+
33+
Raises:
34+
FileNotFoundError: If the config file doesn't exist
35+
json.JSONDecodeError: If the JSON is malformed
36+
ValidationError: If the config doesn't match the schema
37+
SchemaError: If there's an issue with the schema itself
38+
"""
39+
config_path = Path(jupyter_dir) / "mcp" / "config.json"
40+
config_path_str = str(config_path)
41+
42+
# Check if file exists
43+
if not config_path.exists():
44+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
45+
46+
# Get current file modification time
47+
current_mtime = config_path.stat().st_mtime
48+
49+
# Check cache first
50+
if config_path_str in self._cache:
51+
cached_config, cached_mtime = self._cache[config_path_str]
52+
53+
# If file hasn't been modified, return cached version
54+
if current_mtime == cached_mtime:
55+
return cached_config
56+
57+
# File is new or has been modified, read and validate it
58+
try:
59+
with open(config_path) as f:
60+
config = json.load(f)
61+
except json.JSONDecodeError as e:
62+
raise json.JSONDecodeError(
63+
f"Invalid JSON in config file {config_path}: {e.msg}", e.doc, e.pos
64+
)
65+
66+
# Validate against schema
67+
try:
68+
validate(instance=config, schema=self.schema)
69+
except ValidationError as e:
70+
raise ValidationError(f"Configuration validation failed: {e.message}")
71+
except SchemaError as e:
72+
raise SchemaError(f"Schema error: {e.message}")
73+
74+
# Cache the validated configuration and its modification time
75+
self._cache[config_path_str] = (config, current_mtime)
76+
77+
return config
78+
79+
def validate_config(self, config: dict[str, Any]) -> bool:
80+
"""
81+
Validate a configuration object against the schema.
82+
83+
Args:
84+
config (Dict[str, Any]): Configuration object to validate
85+
86+
Returns:
87+
bool: True if valid, raises exception if invalid
88+
"""
89+
validate(instance=config, schema=self.schema)
90+
return True
91+
92+
def clear_cache(self) -> None:
93+
"""
94+
Clear the configuration cache.
95+
"""
96+
self._cache.clear()
97+
98+
def get_cache_info(self) -> dict[str, Any]:
99+
"""
100+
Get information about the cache.
101+
102+
Returns:
103+
Dict[str, Any]: Dictionary with cache statistics
104+
"""
105+
return {
106+
"cached_files": len(self._cache),
107+
"cache_keys": list(self._cache.keys()),
108+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Extended MCP Server Configuration Schema",
4+
"type": "object",
5+
"properties": {
6+
"mcpServers": {
7+
"type": "object",
8+
"patternProperties": {
9+
"^[a-zA-Z0-9_-]+$": {
10+
"type": "object",
11+
"properties": {
12+
"command": {
13+
"type": "string",
14+
"description": "Executable command for local stdio-based MCP server"
15+
},
16+
"args": {
17+
"type": "array",
18+
"description": "Command-line arguments passed to the executable",
19+
"items": { "type": "string" }
20+
},
21+
"url": {
22+
"type": "string",
23+
"format": "uri",
24+
"description": "Remote server URL (e.g. 'https://my-server.com/mcp')"
25+
},
26+
"transport": {
27+
"type": "string",
28+
"enum": ["stdio", "http", "streamable_http", "sse"],
29+
"description": "Communication protocol between client and server"
30+
},
31+
"env": {
32+
"type": "object",
33+
"description": "Optional environment variables passed to the server",
34+
"additionalProperties": { "type": "string" }
35+
},
36+
"disabled": {
37+
"type": "boolean",
38+
"description": "Optional flag to disable this server"
39+
}
40+
},
41+
"anyOf": [
42+
{ "required": ["command", "args"] },
43+
{ "required": ["url"] }
44+
],
45+
"additionalProperties": false
46+
}
47+
},
48+
"additionalProperties": false
49+
}
50+
},
51+
"required": ["mcpServers"],
52+
"additionalProperties": false
53+
}

packages/jupyter-ai/jupyter_ai/personas/base_persona.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,12 @@ def get_workspace_dir(self) -> str:
341341
"""
342342
return self.parent.get_workspace_dir()
343343

344+
def get_mcp_config(self) -> dict[str, Any]:
345+
"""
346+
Returns the MCP config for the current chat.
347+
"""
348+
return self.parent.get_mcp_config()
349+
344350

345351
class GenerationInterrupted(asyncio.CancelledError):
346352
"""Exception raised when streaming is cancelled by the user"""

packages/jupyter-ai/jupyter_ai/personas/persona_manager.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import os
55
from logging import Logger
66
from time import time_ns
7-
from typing import TYPE_CHECKING, ClassVar
7+
from typing import TYPE_CHECKING, Any, ClassVar
88

99
from importlib_metadata import entry_points
1010
from jupyterlab_chat.models import Message
1111
from jupyterlab_chat.ychat import YChat
1212
from traitlets.config import LoggingConfigurable
1313

1414
from ..config_manager import ConfigManager
15+
from ..mcp.mcp_config_loader import MCPConfigLoader
1516
from .base_persona import BasePersona
1617
from .directories import find_dot_dir, find_workspace_dir
1718

@@ -37,6 +38,7 @@ class PersonaManager(LoggingConfigurable):
3738
fileid_manager: BaseFileIdManager
3839
root_dir: str
3940
event_loop: AbstractEventLoop
41+
_mcp_config_loader: MCPConfigLoader
4042

4143
log: Logger # type: ignore
4244
"""
@@ -78,6 +80,9 @@ def __init__(
7880
# Store file ID
7981
self.file_id = room_id.split(":")[2]
8082

83+
# Initialize MCP config loader
84+
self._mcp_config_loader = MCPConfigLoader()
85+
8186
# Load persona classes from entry points.
8287
# This is stored in a class attribute (global to all instances) because
8388
# the entry points are immutable after the server starts, so they only
@@ -273,3 +278,13 @@ def get_workspace_dir(self) -> str:
273278
Returns the path to the workspace directory for the current chat.
274279
"""
275280
return find_workspace_dir(self.get_chat_dir(), root_dir=self.root_dir)
281+
282+
def get_mcp_config(self) -> dict[str, Any]:
283+
"""
284+
Returns the MCP config for the current chat.
285+
"""
286+
jdir = self.get_dotjupyter_dir()
287+
if jdir is None:
288+
return {}
289+
else:
290+
return self._mcp_config_loader.get_config(jdir)

0 commit comments

Comments
 (0)