diff --git a/src/ipython_demo/__init__.py b/src/ipython_demo/__init__.py index e05f8a6..95cc2ad 100644 --- a/src/ipython_demo/__init__.py +++ b/src/ipython_demo/__init__.py @@ -7,6 +7,7 @@ from .events import ( event_from_history_output, ) from .models import ( + AppInfo, CallError, CallEvent, CallEventKind, @@ -28,6 +29,7 @@ from .utils import generate_good_names __all__ = [ "App", + "AppInfo", "CallError", "CallEvent", "CallEventKind", diff --git a/src/ipython_demo/app.py b/src/ipython_demo/app.py index 9f9ab4e..d855543 100644 --- a/src/ipython_demo/app.py +++ b/src/ipython_demo/app.py @@ -2,12 +2,14 @@ import contextlib import io import json from dataclasses import asdict +from datetime import UTC, datetime +from time import monotonic from typing import Literal from uuid import uuid4 from ipython_demo.utils import generate_good_names -from .models import CallOptions, CallResponse, ShellInfo +from .models import AppInfo, CallOptions, CallResponse, ShellInfo from .shell import Shell @@ -15,6 +17,10 @@ class App: """Coordinate named shells and expose frontend-shaped call responses.""" def __init__(self): + self.app_id = uuid4().hex + self.created_at = datetime.now(UTC).isoformat() + self._started_at = monotonic() + self._total_calls = 0 self.shells: dict[str, Shell] = {} self.last_shell: str | None = None @@ -62,6 +68,17 @@ class App: raise KeyError("No shell exists yet") return self.get_shell(self.last_shell) + def info(self) -> AppInfo: + """Return runtime metadata without initializing or inspecting shells.""" + return AppInfo( + app_id=self.app_id, + created_at=self.created_at, + uptime_seconds=monotonic() - self._started_at, + shell_count=len(self.shells), + last_shell=self.last_shell, + total_calls=self._total_calls, + ) + def get_shell_2(self, shell_name: Literal["last"] | str) -> ShellInfo: """Return frontend-safe metadata for a single shell.""" if shell_name == "last": @@ -80,6 +97,7 @@ class App: options = CallOptions() selected_name, shell = self._select_shell(shell_name) call_id = uuid4().hex + self._total_calls += 1 execution, events = shell.run_cell(code, call_id=call_id) return CallResponse( call_id=call_id, diff --git a/src/ipython_demo/models.py b/src/ipython_demo/models.py index c634479..e6ed7b9 100644 --- a/src/ipython_demo/models.py +++ b/src/ipython_demo/models.py @@ -116,3 +116,15 @@ class ShellInfo: created_at: str last_used_at: str | None capabilities: tuple[str, ...] + + +@dataclass +class AppInfo: + """Small, transport-safe summary of one running application instance.""" + + app_id: str + created_at: str + uptime_seconds: float + shell_count: int + last_shell: str | None + total_calls: int diff --git a/src/ipython_mcp/app.py b/src/ipython_mcp/app.py index 3b6a288..b6a2e1e 100644 --- a/src/ipython_mcp/app.py +++ b/src/ipython_mcp/app.py @@ -1,20 +1,32 @@ # FastMCP is not FastAPI: this module exposes MCP tools, not HTTP routes. -from typing import Literal +from typing import Annotated, Literal from fastmcp import FastMCP +from pydantic import Field import ipython_demo -from ipython_demo.models import CallOptions, CallResponse, ShellInfo +from ipython_demo.models import AppInfo, CallOptions, CallResponse, ShellInfo app = FastMCP(name="IPython Demo") shell_app = ipython_demo.App() -@app.tool +@app.tool(title="Get app info") +def get_app_info() -> AppInfo: + """Return metadata about this running IPython application.""" + return shell_app.info() + + +@app.tool(title="Run code") def run_code( code: str, - shell_name: Literal["last", "new"] | str = "last", + shell_name: Annotated[ + Literal["last", "new"] | str, + Field( + description="The name of the shell to run the code in. Use 'new' to create a new shell, or 'last' to use the most recently selected shell." + ), + ] = "last", options: CallOptions | None = None, ) -> CallResponse: """Run code in a named shell and return a frontend-facing call response. @@ -24,14 +36,21 @@ def run_code( return shell_app.run_code(code, shell_name, options=options) -@app.tool +@app.tool(title="List shells") def list_shells() -> list[ShellInfo]: """Return frontend-safe metadata for all known shells.""" return shell_app.list_shells() -@app.tool -def get_shell(shell_name: Literal["last"] | str) -> ShellInfo: +@app.tool(title="Get shell") +def get_shell( + shell_name: Annotated[ + Literal["last"] | str, + Field( + description="The name of the shell to retrieve. Use 'last' to get the most recently selected shell." + ), + ], +) -> ShellInfo: """Return frontend-safe metadata for a single shell.""" return ( shell_app.get_last_shell() @@ -40,13 +59,13 @@ def get_shell(shell_name: Literal["last"] | str) -> ShellInfo: ) -@app.tool +@app.tool(title="Create shell") def create_shell() -> ShellInfo: """Create a fresh named shell and return its metadata.""" return shell_app.create_shell() -@app.tool +@app.tool(title="Get last shell") def get_last_shell() -> ShellInfo: """ Return metadata for the most recently selected shell. diff --git a/src/ipython_webapp/app.py b/src/ipython_webapp/app.py index 27632a2..deedd6e 100644 --- a/src/ipython_webapp/app.py +++ b/src/ipython_webapp/app.py @@ -10,6 +10,11 @@ app = FastAPI(title="IPython Demo", version="0.1.0") ShellApp = App() # global, scary +@app.get("/info") +def get_info(): + return ShellApp.info() + + @app.get("/shells") def list_shells(): shells = ShellApp.list_shells() diff --git a/src/langchain_demo/app.py b/src/langchain_demo/app.py index a4f78bc..b2bf458 100644 --- a/src/langchain_demo/app.py +++ b/src/langchain_demo/app.py @@ -10,6 +10,7 @@ from pydantic import SecretStr from .ipython_wrapper import ( create_shell, + get_app_info, get_last_shell, get_shell, list_shells, @@ -38,7 +39,14 @@ llm = ChatOpenAI( agent = create_agent( llm, - tools=[create_shell, get_last_shell, get_shell, list_shells, run_code], + tools=[ + get_app_info, + create_shell, + get_last_shell, + get_shell, + list_shells, + run_code, + ], checkpointer=None, # soon ) diff --git a/src/langchain_demo/ipython_wrapper.py b/src/langchain_demo/ipython_wrapper.py index c94c420..43a38af 100644 --- a/src/langchain_demo/ipython_wrapper.py +++ b/src/langchain_demo/ipython_wrapper.py @@ -3,11 +3,17 @@ from typing import Literal from langchain.tools import tool from ipython_demo import App -from ipython_demo.models import CallOptions, CallResponse, ShellInfo +from ipython_demo.models import AppInfo, CallOptions, CallResponse, ShellInfo app = App() # global, again +@tool +def get_app_info() -> AppInfo: + """Return metadata about this running IPython application.""" + return app.info() + + # should be similar to mcp/webapp. @tool def run_code( diff --git a/tests/test_info.py b/tests/test_info.py new file mode 100644 index 0000000..55d2b3f --- /dev/null +++ b/tests/test_info.py @@ -0,0 +1,48 @@ +import unittest + +from fastapi.testclient import TestClient + +from ipython_demo import App, AppInfo +from ipython_mcp.app import app as mcp_app +from ipython_webapp.app import app as web_app +from langchain_demo.ipython_wrapper import get_app_info + + +class AppInfoTests(unittest.IsolatedAsyncioTestCase): + def test_app_info_describes_runtime_and_updates_after_execution(self): + app = App() + + before = app.info() + self.assertIsInstance(before, AppInfo) + self.assertEqual(before.shell_count, 0) + self.assertIsNone(before.last_shell) + self.assertEqual(before.total_calls, 0) + self.assertGreaterEqual(before.uptime_seconds, 0) + + app.run_code("1 + 1", shell_name="info-test") + after = app.info() + + self.assertEqual(after.shell_count, 1) + self.assertEqual(after.last_shell, "info-test") + self.assertEqual(after.total_calls, 1) + self.assertGreaterEqual(after.uptime_seconds, before.uptime_seconds) + + def test_web_app_exposes_info(self): + response = TestClient(web_app).get("/info") + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json()["shell_count"], int) + + async def test_mcp_app_exposes_info_tool(self): + tools = await mcp_app.list_tools() + + self.assertIn("get_app_info", {tool.name for tool in tools}) + + def test_langchain_exposes_info_tool(self): + result = get_app_info.invoke({}) + + self.assertIsInstance(result, AppInfo) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 86d74ed..bc2abd2 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -14,6 +14,7 @@ class MCPAppTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( {tool.name for tool in tools}, { + "get_app_info", "run_code", "list_shells", "get_shell",