diff --git a/README.md b/README.md index b74c2e6df..627b114f9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A Python-based desktop journaling toolkit that captures screen and audio activit - **Intelligent Insights** - Automated daily summaries, meeting detection, and entity extraction - **Facet Organization** - Group content by project or context (work, personal, etc.) - **Web Interface** - Review transcripts, calendar views, entity tracking, and AI chat -- **Agent System** - Extensible AI agents with MCP tool integration +- **Agent System** - Extensible AI agents with callable tool workflows ## Architecture diff --git a/apps/chat/tools.py b/apps/chat/tools.py index fd7055854..6c6943411 100644 --- a/apps/chat/tools.py +++ b/apps/chat/tools.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""MCP tools for the chat app.""" +"""Tool functions for the chat app.""" import json from pathlib import Path @@ -10,22 +10,15 @@ from typing import Any from fastmcp import Context from think.facets import get_agent_info -from think.mcp import HINTS, register_tool from think.utils import get_journal -# Declare pack membership - add send_message to journal pack -TOOL_PACKS = { - "journal": ["send_message"], -} - -@register_tool(annotations=HINTS) def send_message( body: str, facet: str | None = None, context: Context | None = None ) -> dict[str, Any]: """Send a message to the user's inbox for asynchronous communication. - This tool allows MCP agents and tools to leave messages in the user's inbox + This tool allows agents and tool workflows to leave messages in the user's inbox that can be reviewed later through the chat app interface. Messages appear as unread notifications and can be archived after review. Use this for: - Alerting about things or issues that need attention diff --git a/apps/entities/tools.py b/apps/entities/tools.py index 357d1af83..1c95c8e2b 100644 --- a/apps/entities/tools.py +++ b/apps/entities/tools.py @@ -1,10 +1,9 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""MCP tools for entity management. +"""Tool functions for entity management. -This module provides the entity MCP tools for the entities app. -Tools are auto-discovered and registered via the @register_tool decorator. +This module provides callable entity tool functions for the entities app. """ import re @@ -24,23 +23,8 @@ from think.entities import ( validate_aka_uniqueness, ) from think.facets import log_tool_action -from think.mcp import HINTS, register_tool from think.utils import now_ms -# Declare tool pack - creates the "entities" pack with all entity tools -TOOL_PACKS = { - "entities": [ - "entity_list", - "entity_detect", - "entity_attach", - "entity_update", - "entity_add_aka", - "entity_observations", - "entity_observe", - ], -} - - # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- @@ -108,11 +92,10 @@ def _resolve_or_error( # ----------------------------------------------------------------------------- -# MCP Tools +# Tool functions # ----------------------------------------------------------------------------- -@register_tool(annotations=HINTS) def entity_list(facet: str, day: str | None = None) -> dict[str, Any]: """List entities for a facet. @@ -149,7 +132,6 @@ def entity_list(facet: str, day: str | None = None) -> dict[str, Any]: } -@register_tool(annotations=HINTS) def entity_detect( day: str, facet: str, @@ -269,7 +251,6 @@ def entity_detect( } -@register_tool(annotations=HINTS) def entity_attach( facet: str, type: str, entity: str, description: str, context: Context | None = None ) -> dict[str, Any]: @@ -398,7 +379,6 @@ def entity_attach( } -@register_tool(annotations=HINTS) def entity_update( facet: str, entity: str, @@ -490,7 +470,6 @@ def entity_update( } -@register_tool(annotations=HINTS) def entity_add_aka( facet: str, entity: str, aka: str, context: Context | None = None ) -> dict[str, Any]: @@ -601,7 +580,6 @@ def entity_add_aka( } -@register_tool(annotations=HINTS) def entity_observations(facet: str, entity: str) -> dict[str, Any]: """List observations for an attached entity. @@ -651,7 +629,6 @@ def entity_observations(facet: str, entity: str) -> dict[str, Any]: } -@register_tool(annotations=HINTS) def entity_observe( facet: str, entity: str, diff --git a/apps/todos/tools.py b/apps/todos/tools.py index 98ec29cf8..eb6236f0f 100644 --- a/apps/todos/tools.py +++ b/apps/todos/tools.py @@ -1,35 +1,20 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""MCP tools and resources for todo management. +"""Todo management tool functions. -This module provides the todo MCP tools and resource handler for the todos app. -Tools are auto-discovered and registered via the @register_tool decorator. -The resource is registered via @mcp.resource decorator. +This module provides the todo tool functions for the todos app. """ from datetime import datetime from typing import Any from fastmcp import Context -from fastmcp.resources import TextResource from apps.todos import todo from think.facets import log_tool_action -from think.mcp import HINTS, mcp, register_tool -# Declare tool pack - creates the "todo" pack with all todo tools -TOOL_PACKS = { - "todo": ["todo_list", "todo_add", "todo_cancel", "todo_done", "todo_upcoming"], -} - -# ----------------------------------------------------------------------------- -# MCP Tools -# ----------------------------------------------------------------------------- - - -@register_tool(annotations=HINTS) def todo_list(day: str, facet: str, day_to: str | None = None) -> dict[str, Any]: """Return the numbered todo checklist for a day or date range in a specific facet. @@ -120,7 +105,6 @@ def todo_list(day: str, facet: str, day_to: str | None = None) -> dict[str, Any] return {"error": f"Failed to list todos: {exc}"} -@register_tool(annotations=HINTS) def todo_add( day: str, facet: str, line_number: int, text: str, context: Context | None = None ) -> dict[str, Any]: @@ -179,7 +163,6 @@ def todo_add( return {"error": f"Failed to add todo: {exc}"} -@register_tool(annotations=HINTS) def todo_cancel( day: str, facet: str, line_number: int, context: Context | None = None ) -> dict[str, Any]: @@ -216,7 +199,6 @@ def todo_cancel( return {"error": f"Failed to cancel todo: {exc}"} -@register_tool(annotations=HINTS) def todo_done( day: str, facet: str, line_number: int, context: Context | None = None ) -> dict[str, Any]: @@ -253,7 +235,6 @@ def todo_done( return {"error": f"Failed to complete todo: {exc}"} -@register_tool(annotations=HINTS) def todo_upcoming(limit: int = 20, facet: str | None = None) -> dict[str, Any]: """Return upcoming todos across future days as markdown sections. @@ -286,31 +267,3 @@ def todo_upcoming(limit: int = 20, facet: str | None = None) -> dict[str, Any]: return {"limit": limit, "facet": facet, "markdown": markdown} except Exception as exc: # pragma: no cover - unexpected failure return {"error": f"Failed to load upcoming todos: {exc}"} - - -# ----------------------------------------------------------------------------- -# MCP Resource -# ----------------------------------------------------------------------------- - - -@mcp.resource("journal://todo/{facet}/{day}") -def get_todo(facet: str, day: str) -> TextResource: - """Return the facet-scoped todo checklist for a specific day.""" - checklist = todo.TodoChecklist.load(day, facet) - - if not checklist.exists: - facet_path = checklist.path.parents[1] # facets/{facet}/todos - if not facet_path.is_dir(): - text = f"No todos folder for facet '{facet}'." - else: - text = f"(No todos recorded for {day} in facet '{facet}'.)" - else: - text = checklist.display() - - return TextResource( - uri=f"journal://todo/{facet}/{day}", - name=f"Todos: {facet}/{day}", - description=f"Checklist entries for facet '{facet}' on {day}", - mime_type="text/plain", - text=text, - ) diff --git a/apps/transcripts/call.py b/apps/transcripts/call.py index d4fdfe648..6814d1fa6 100644 --- a/apps/transcripts/call.py +++ b/apps/transcripts/call.py @@ -4,7 +4,7 @@ """CLI commands for transcript browsing. Provides human-friendly CLI access to transcript operations, paralleling the -MCP tools in ``think/resources/transcripts.py`` but optimized for terminal use. +transcript helper functions in ``think.cluster`` but optimized for terminal use. Auto-discovered by ``think.call`` and mounted as ``sol call transcripts ...``. """ diff --git a/docs/APPS.md b/docs/APPS.md index 709dd0167..600ea1713 100644 --- a/docs/APPS.md +++ b/docs/APPS.md @@ -37,7 +37,7 @@ All apps are served via a shared route handler at `/app/{app_name}`. You only ne apps/my_app/ ├── workspace.html # Required: Main content template ├── routes.py # Optional: Flask blueprint (only if custom routes needed) -├── tools.py # Optional: MCP tool extensions (auto-discovered) +├── tools.py # Optional: App tool functions for agent workflows ├── call.py # Optional: CLI commands via Typer (auto-discovered) ├── events.py # Optional: Server-side event handlers (auto-discovered) ├── app.json # Optional: Metadata (icon, label, facet support) @@ -55,7 +55,7 @@ apps/my_app/ |------|----------|---------| | `workspace.html` | **Yes** | Main app content (rendered in container) | | `routes.py` | No | Flask blueprint for custom routes (API endpoints, forms, etc.) | -| `tools.py` | No | MCP tool extensions for AI agents (auto-discovered) | +| `tools.py` | No | Callable tool functions for AI agent workflows | | `call.py` | No | CLI commands via Typer, accessed as `sol call ` (auto-discovered) | | `events.py` | No | Server-side Callosum event handlers (auto-discovered) | | `app.json` | No | Icon, label, facet support overrides | @@ -236,31 +236,19 @@ Submenus appear as hover pop-outs on menu bar icons. Items support `id`, `label` --- -### 6. `tools.py` - MCP Tool Extensions +### 6. `tools.py` - App Tool Functions -Define custom MCP tools for your app that are automatically discovered and registered. +Define plain callable tool functions for your app in `tools.py`. **Key Points:** -- Only create `tools.py` if your app needs custom AI agent tools -- Tools use the `@register_tool` decorator from `think.mcp` -- Automatically discovered and loaded at server startup -- Errors in one app's tools don't prevent other apps from loading -- Tools become available to all AI agents via the MCP protocol - -**Required imports:** -```python -from think.mcp import register_tool, HINTS -``` - -**Decorator usage:** Apply `@register_tool(annotations=HINTS)` to plain functions that return dict responses. Functions should include type hints and docstrings for AI agent context. - -**Discovery behavior:** The MCP server scans `apps/*/tools.py` at startup, imports modules, and registers decorated functions. Private apps (directories starting with `_`) are skipped. +- Only create `tools.py` if your app needs reusable tool functions for agent workflows +- Keep functions simple: typed inputs, dict-style outputs, clear docstrings +- Put shared logic in your app/module layer and call it from these functions **Reference implementations:** -- Discovery logic: `think/mcp.py` - `_discover_app_tools()` function -- Testing: `tests/integration/test_app_tool_discovery.py` - Error handling and edge cases -- App tool examples: `apps/todos/tools.py`, `apps/entities/tools.py` - Tool implementation patterns -- Core tool example: `think/tools/search.py` - Search tool implementation +- `apps/todos/tools.py` +- `apps/entities/tools.py` +- `apps/chat/tools.py` --- @@ -282,10 +270,10 @@ import typer app = typer.Typer(help="Description of your app commands.") ``` -**Command pattern:** Define commands using Typer's `@app.command()` decorator with `typer.Argument` for positional args and `typer.Option` for flags. Call the underlying data layer directly (not MCP tool functions) and print output via `typer.echo()`. +**Command pattern:** Define commands using Typer's `@app.command()` decorator with `typer.Argument` for positional args and `typer.Option` for flags. Call the underlying data layer directly (not tool helper wrappers) and print output via `typer.echo()`. -**CLI vs MCP tools:** CLI commands parallel MCP tools but are optimized for interactive terminal use. Key differences: -- No MCP `Context` parameter — CLI has no MCP context +**CLI vs tool functions:** CLI commands parallel tool functions but are optimized for interactive terminal use. Key differences: +- Tool functions may accept a `Context` parameter for caller metadata; CLI has no context object - No guard parameters (e.g., `line_number`, `observation_number`) — auto-compute them internally since interactive users don't need optimistic locking - Print formatted text instead of returning dicts - Use `typer.Exit(1)` for errors instead of returning error dicts diff --git a/docs/CORTEX.md b/docs/CORTEX.md index 9a33de5ae..a675f515f 100644 --- a/docs/CORTEX.md +++ b/docs/CORTEX.md @@ -267,24 +267,18 @@ When spawning an agent: - Loads agent configuration using `get_agent()` from `think/muse.py` - Merges request parameters with agent defaults - Resolves provider and model based on context - - Expands tool pack names to tool lists 3. The agent validates the config via `validate_config()` before execution 4. Instructions are built with three components: - `system_instruction`: `journal.md` (shared base prompt, cacheable) - `extra_context`: Runtime context (facets, generators list, datetime) - `user_instruction`: The agent's `.md` file content -Agents define specialized behaviors, tool usage patterns, and facet expertise. Available agents can be discovered using `get_muse_configs(has_tools=True)` or by listing files in the `muse/` directory (agents are `.md` files with a `tools` field). +Agents define specialized behaviors and facet expertise. Available agents can be discovered using `get_muse_configs(type="cogitate")` or by listing files in the `muse/` directory. ### Agent Configuration Options The JSON frontmatter for an agent can include: - `max_tokens`: Maximum response token limit -- `tools`: MCP tools configuration (string or array) - - String: Comma-separated pack names (e.g., `"journal"`, `"journal, todo"`) - expanded via `get_tools()` - - Available packs: `journal`, `todo`, `facets`, `entities`, `apps` - - Array: Explicit list of tool names (e.g., `["search_insights", "get_facet"]`) - - If omitted, defaults to "default" pack (alias for "journal") - `schedule`: Scheduling configuration for automated execution - `"daily"`: Run automatically at midnight each day - `priority`: Execution order for scheduled prompts (integer, **required** for scheduled prompts) @@ -321,20 +315,6 @@ This allows controlling model selection via tier configuration rather than hardc } ``` -## MCP Tools Integration - -The Model Context Protocol (MCP) provides tools for agent-journal interaction: - -### Backend Support -- **OpenAI, Anthropic, Google**: Full MCP tool support via HTTP transport - -### Tool Discovery -MCP tools are provided by the `think.mcp` FastMCP server, which: -- Runs inside Cortex as a background HTTP service -- Shares its URL directly with agent runs (`mcp_server_url`) so no discovery file is needed -- Exposes journal search and retrieval capabilities -- Available tools can be discovered via the MCP service endpoint - ## Agent Providers The system supports multiple AI providers, each implementing the same event interface: @@ -383,8 +363,7 @@ To force an agent to run for all facets regardless of activity, set `"always": t "title": "Facet Newsletter Generator", "schedule": "daily", "priority": 10, - "multi_facet": true, - "tools": "journal,facets" + "multi_facet": true } ``` @@ -393,8 +372,7 @@ To force an agent to run for all facets regardless of activity, set `"always": t "title": "Facet Auditor", "schedule": "daily", "multi_facet": true, - "always": true, - "tools": "journal,facets" + "always": true } ``` @@ -406,8 +384,7 @@ Segment agents can also be multi-facet. Active facets are determined from the `f { "title": "Facet Activity Tracker", "schedule": "segment", - "multi_facet": true, - "tools": "journal,facets" + "multi_facet": true } ``` @@ -427,7 +404,6 @@ Multi-facet segment agents spawn once per non-muted facet in this array. Muted f The `sol supervisor` command provides process management for the Cortex ecosystem: - Starts and monitors the Cortex file watcher service -- Starts and monitors the MCP tools HTTP server - Handles process restarts on failure - Monitors system health indicators - Triggers `sol dream` at midnight for daily processing (generators + agents) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index b98e832f6..e293a9184 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -12,7 +12,7 @@ Each provider module in `think/providers/` must export three functions: |----------|---------| | `run_generate()` | Synchronous text generation, returns `GenerateResult` | | `run_agenerate()` | Asynchronous text generation, returns `GenerateResult` | -| `run_cogitate()` | Tool-calling execution with MCP integration | +| `run_cogitate()` | Tool-calling execution with event streaming | See `think/providers/__init__.py` for the canonical export list and `think/providers/google.py` as a reference implementation. @@ -108,7 +108,7 @@ class GenerateResult(TypedDict, total=False): ## run_cogitate() -Handles tool-calling execution with MCP integration. +Handles tool-calling execution. ```python async def run_cogitate( @@ -124,7 +124,6 @@ async def run_cogitate( - `system_instruction`: System instruction (journal.md for agents) - `extra_context`: Runtime context (facets, insights list, datetime) as first user message - `user_instruction`: Agent-specific prompt as second user message -- `mcp_server_url`: URL for MCP tool server (tools enabled when present) - `tools`: Optional list of allowed tool names - `agent_id`, `name`: Identity for logging and tool calls - `continue_from`: Agent ID for conversation continuation diff --git a/docs/THINK.md b/docs/THINK.md index 72c8b00d5..716cba75d 100644 --- a/docs/THINK.md +++ b/docs/THINK.md @@ -19,7 +19,6 @@ The package exposes several commands: - `sol dream` runs generators and agents for a single day via Cortex. - `sol agents` is the unified CLI for tool agents and generators (spawned by Cortex, NDJSON protocol). - `sol supervisor` monitors observation heartbeats. Use `--no-observers` to disable local capture (sense still runs for remote uploads and imports). -- `sol mcp` starts an MCP server exposing search capabilities for both summary text and raw transcripts. - `sol cortex` starts a Callosum-based service for managing AI agent instances and generators. - `sol muse` lists available agents and generators with their configuration. Use `sol muse ` to see details, and `sol muse --prompt` to see the fully composed prompt that would be sent to the LLM. @@ -27,7 +26,6 @@ The package exposes several commands: sol call transcripts read YYYYMMDD [--start HHMMSS --length MINUTES] sol dream [--day YYYYMMDD] [--segment HHMMSS_LEN] [--force] [--run NAME] sol supervisor [--no-observers] -sol mcp [--transport http] [--port PORT] [--path PATH] sol cortex [--host HOST] [--port PORT] [--path PATH] sol muse [--schedule daily|segment] [--json] sol muse [--prompt] [--day YYYYMMDD] [--segment HHMMSS_LEN] [--full] @@ -41,10 +39,8 @@ is loaded automatically by most commands. ## Service Discovery -The MCP HTTP server now runs inside Cortex itself. When Cortex starts it passes -the URL directly to each agent request (`mcp_server_url`). Utilities that need -tool metadata, such as `sol planner`, query the registered tools directly and -no discovery files or environment variables are required. +Cortex manages agent execution directly. Utilities that need runtime metadata +query the active services without discovery files or environment variables. ## Automating daily processing @@ -172,7 +168,7 @@ Each provider lives in `think/providers/` and exposes a common interface: - `run_generate()` - Sync text generation, returns `GenerateResult` - `run_agenerate()` - Async text generation, returns `GenerateResult` -- `run_cogitate()` - Tool-calling execution with MCP integration and event streaming +- `run_cogitate()` - Tool-calling execution with event streaming For direct LLM calls, use `think.models.generate()` or `think.models.agenerate()` which automatically routes to the configured provider based on context. @@ -215,14 +211,13 @@ print(f"Found {agents_info['live_count']} running agents") ``` # Muse Module -AI agent system and MCP tooling for solstone. +AI agent system for solstone. ## Commands | Command | Purpose | |---------|---------| | `sol cortex` | Agent orchestration service | -| `sol mcp` | MCP tool server (runs inside Cortex) | | `sol agents` | Direct agent invocation (testing only) | ## Architecture @@ -230,7 +225,6 @@ AI agent system and MCP tooling for solstone. ``` Cortex (orchestrator) ├── Callosum connection (events) - ├── MCP HTTP server (tools) └── Agent subprocess management ↓ Providers (openai, google, anthropic) @@ -250,7 +244,6 @@ Providers implement `run_generate()`, `run_agenerate()`, and `run_cogitate()` fu - **cortex.py** - Central agent manager, file watcher, event distribution, spawns agents.py - **cortex_client.py** - Client functions: `cortex_request()`, `cortex_agents()`, `wait_for_agents()` -- **mcp.py** - FastMCP server with journal search tools - **agents.py** - Unified CLI entry point for both tool-using agents and generators (NDJSON protocol) - **models.py** - Unified `generate()`/`agenerate()` API, provider routing, token logging - **batch.py** - `Batch` class for concurrent LLM requests with dynamic queuing diff --git a/sol.py b/sol.py index 45368e78c..cd7963ee4 100644 --- a/sol.py +++ b/sol.py @@ -58,10 +58,9 @@ COMMANDS: dict[str, str] = { "observer": "observe.observer", "observe-linux": "observe.linux.observer", "observe-macos": "observe.macos.observer", - # AI agents and MCP (formerly muse package) + # AI agents (formerly muse package) "agents": "think.agents", "cortex": "think.cortex", - "mcp": "think.mcp", "muse": "think.muse_cli", "call": "think.call", # convey package - web UI @@ -107,7 +106,6 @@ GROUPS: dict[str, list[str]] = { "Muse (AI agents)": [ "agents", "cortex", - "mcp", "muse", "call", ], diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e3b8c6dd2..314134436 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -43,21 +43,3 @@ def integration_journal_path(tmp_path_factory): def integration_test_data(): """Provide path to integration test data.""" return Path(__file__).parent / "data" - - -@pytest.fixture(autouse=True) -def disable_cortex_mcp_server(monkeypatch): - """Prevent CortexService from starting the MCP server during tests.""" - - def _noop_start(self): - self.mcp_server_url = None - return None - - monkeypatch.setattr( - "think.cortex.CortexService._start_mcp_server", - _noop_start, - ) - monkeypatch.setattr( - "think.cortex.CortexService._wait_for_mcp_server", - lambda *args, **kwargs: None, - ) diff --git a/tests/integration/test_anthropic_provider.py b/tests/integration/test_anthropic_provider.py index dd0dd4b1f..7dba3e1a7 100644 --- a/tests/integration/test_anthropic_provider.py +++ b/tests/integration/test_anthropic_provider.py @@ -49,7 +49,7 @@ def test_anthropic_provider_basic(): env["JOURNAL_PATH"] = journal_path env["ANTHROPIC_API_KEY"] = api_key - # Create NDJSON input (no mcp_server_url = no MCP tools) + # Create NDJSON input (no tool config) ndjson_input = json.dumps( { "prompt": "what is 1+1? Just give me the number.", diff --git a/tests/integration/test_app_tool_discovery.py b/tests/integration/test_app_tool_discovery.py deleted file mode 100644 index 1faa348cb..000000000 --- a/tests/integration/test_app_tool_discovery.py +++ /dev/null @@ -1,174 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Integration tests for app-level MCP tool discovery.""" - -import os -import sys - -import pytest - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_app_tools_discovery_mechanism(integration_journal_path): - """Test that app tool discovery mechanism runs without errors. - - This test verifies that _discover_app_tools() executes successfully - and that the MCP server functions correctly whether or not any apps - have tools.py files. - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - from think.mcp import _discover_app_tools, mcp - - # Call discovery - should not raise exceptions - _discover_app_tools() - - # Verify MCP server still has core tools registered - tools = await mcp.get_tools() - tool_names = set(tools.keys()) - - # Core tools should always be present - core_tools = {"todo_list", "search_journal", "entity_list", "get_facet"} - assert core_tools.issubset( - tool_names - ), f"Core tools missing after discovery: {core_tools - tool_names}" - - # If any real apps have tools.py, they would be loaded too - # We don't assert specific app tools exist since that depends on the codebase state - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_app_tools_discovery_graceful_failure( - integration_journal_path, tmp_path, caplog -): - """Test that broken app tools don't prevent server startup. - - This test verifies the error handling behavior by attempting to import - a malformed tools module. Since _discover_app_tools() scans the real - apps/ directory (not tmp_path), we test the error handling by creating - a module that will fail during import but verifying the system continues. - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - # Test the error handling by importing a module with an error - # This simulates what would happen with a broken app tools.py - import importlib - import logging - - logger = logging.getLogger("think.mcp") - - # Clear any existing handlers and set up fresh logging - logger.handlers.clear() - logger.setLevel(logging.ERROR) - - # Create a handler that captures to caplog - handler = logging.StreamHandler() - handler.setLevel(logging.ERROR) - logger.addHandler(handler) - - # Test that attempting to import a non-existent module is handled gracefully - try: - caplog.clear() - with caplog.at_level(logging.ERROR, logger="think.mcp"): - # Simulate what _discover_app_tools does with a broken import - try: - importlib.import_module("apps.nonexistent_broken_app.tools") - except Exception as e: - # This is what _discover_app_tools does - logger.error( - f"Failed to load tools from app 'test_broken': {e}", exc_info=True - ) - - # Verify error was logged - error_logs = [ - record.message for record in caplog.records if record.levelname == "ERROR" - ] - assert len(error_logs) > 0, "Expected error log for broken import" - assert any( - "Failed to load tools" in log for log in error_logs - ), f"Expected 'Failed to load tools' in logs: {error_logs}" - - finally: - logger.handlers.clear() - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_app_tools_no_apps_directory(integration_journal_path, tmp_path, caplog): - """Test that missing apps directory is handled gracefully.""" - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - # Don't create apps directory at all - sys.path.insert(0, str(tmp_path)) - - try: - import logging - - logging.basicConfig(level=logging.DEBUG) - - from think.mcp import _discover_app_tools - - with caplog.at_level(logging.DEBUG): - # Should not raise an exception - _discover_app_tools() - - # Should log debug message about missing directory - # debug_logs available in caplog.records if needed - # This will use the real apps dir, so no debug message expected - # The test mainly verifies no crash occurs - - finally: - sys.path.remove(str(tmp_path)) - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_app_tools_skip_private_directories( - integration_journal_path, tmp_path, caplog -): - """Test that private/hidden directories are skipped.""" - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - apps_dir = tmp_path / "apps" - apps_dir.mkdir() - - # Create private directory with tools.py - private_app_dir = apps_dir / "_private_app" - private_app_dir.mkdir() - tools_py = private_app_dir / "tools.py" - tools_py.write_text( - '''"""Private app tools.""" -from typing import Any -from think.mcp import register_tool, HINTS - -@register_tool(annotations=HINTS) -def private_tool() -> dict[str, Any]: - """Should not be registered.""" - return {"status": "private"} -''', - encoding="utf-8", - ) - - (private_app_dir / "__init__.py").write_text("", encoding="utf-8") - (apps_dir / "__init__.py").write_text("", encoding="utf-8") - - sys.path.insert(0, str(tmp_path)) - - try: - from think.mcp import _discover_app_tools, mcp - - _discover_app_tools() - - # Verify private tool was NOT registered - tools = await mcp.get_tools() - tool_names = set(tools.keys()) - - assert ( - "private_tool" not in tool_names - ), "Private app tool should not be registered" - - finally: - sys.path.remove(str(tmp_path)) diff --git a/tests/integration/test_google_provider.py b/tests/integration/test_google_provider.py index 1b180209f..70dc1cdfc 100644 --- a/tests/integration/test_google_provider.py +++ b/tests/integration/test_google_provider.py @@ -49,7 +49,7 @@ def test_google_provider_basic(): env["JOURNAL_PATH"] = journal_path env["GOOGLE_API_KEY"] = api_key - # Create NDJSON input (no mcp_server_url = no MCP tools) + # Create NDJSON input (no tool config) ndjson_input = json.dumps( { "prompt": "what is 1+1? Just give me the number.", diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py deleted file mode 100644 index 4d0347a33..000000000 --- a/tests/integration/test_mcp_server.py +++ /dev/null @@ -1,363 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Integration tests for MCP server with full protocol testing.""" - -import json -import os - -import pytest -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_mcp_server_tool_registration(integration_journal_path): - """Test that MCP server registers all tools correctly via direct API. - - This is a fast smoke test that verifies all tools and resources are - properly registered after the modular refactoring. It doesn't test - the MCP protocol itself, just the registration. - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - # Import after setting JOURNAL_PATH - from think.mcp import TOOL_PACKS, mcp - - # Get all registered tools - tools = await mcp.get_tools() - - # Verify all expected tools are registered - expected_tools = set() - for pack_tools in TOOL_PACKS.values(): - expected_tools.update(pack_tools) - - registered_tool_names = set(tools.keys()) - - # Assert all tools are present - assert expected_tools.issubset( - registered_tool_names - ), f"Missing tools: {expected_tools - registered_tool_names}" - - # Verify specific tool metadata - assert "todo_list" in tools - assert tools["todo_list"].name == "todo_list" - # Parameters is a JSON schema dict - tool_params = tools["todo_list"].parameters - if isinstance(tool_params, dict): - # Check for JSON schema structure - if "properties" in tool_params: - assert "day" in tool_params["properties"] - assert "facet" in tool_params["properties"] - else: - assert "day" in tool_params - else: - # It's a list of parameter objects - param_names = [p.name if hasattr(p, "name") else p for p in tool_params] - assert "day" in param_names - - assert "search_journal" in tools - assert tools["search_journal"].name == "search_journal" - - assert "entity_list" in tools - assert tools["entity_list"].name == "entity_list" - - # Get all registered resources - resources = await mcp.get_resources() - - # Resources may be empty if using templates, check resource templates instead - if len(resources) == 0: - # Try getting resource templates - try: - templates = await mcp.get_resource_templates() - template_uris = [t.uriTemplate for t in templates] - assert any("journal://insight" in uri for uri in template_uris) - assert any("journal://transcripts" in uri for uri in template_uris) - except AttributeError: - # Older FastMCP version, skip resource check - pass - else: - # Verify key resources are present - resource_uris = [r.uri for r in resources] - assert any("journal://insight" in uri for uri in resource_uris) - assert any("journal://transcripts" in uri for uri in resource_uris) - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_mcp_server_stdio_e2e(integration_journal_path): - """Test MCP server end-to-end via stdio transport with actual tool call. - - This test validates the complete MCP stack: - 1. Server starts via stdio transport - 2. Client can connect and initialize - 3. Tools are discoverable via MCP protocol - 4. Tools can be invoked and return correct results - 5. Resources are accessible - 6. Error handling works properly - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - # Create test todo for the test - facet = "test-facet" - day = "20991231" # Future date to avoid validation issues - - facets_dir = integration_journal_path / "facets" / facet - facets_dir.mkdir(parents=True, exist_ok=True) - - # Create facet.json - facet_json = facets_dir / "facet.json" - facet_json.write_text( - json.dumps({"title": "Test Facet", "description": "Integration test"}), - encoding="utf-8", - ) - - # Create todos directory with a test todo (JSONL format) - todos_dir = facets_dir / "todos" - todos_dir.mkdir(exist_ok=True) - todo_file = todos_dir / f"{day}.jsonl" - todo_file.write_text( - json.dumps({"text": "Integration test todo"}) + "\n", encoding="utf-8" - ) - - # Configure server parameters - use sol mcp entry point - server_params = StdioServerParameters( - command="sol", - args=["mcp", "--transport", "stdio"], - env={**os.environ, "JOURNAL_PATH": str(integration_journal_path)}, - ) - - # Connect to server via stdio - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the session - await session.initialize() - - # Test 1: List all tools - tools_result = await session.list_tools() - tool_names = [tool.name for tool in tools_result.tools] - - # Verify key tools are present from each module - assert "todo_list" in tool_names, "todo tool missing" - assert "todo_add" in tool_names, "todo tool missing" - assert "search_journal" in tool_names, "search tool missing" - assert "get_events" in tool_names, "events tool missing" - assert "entity_list" in tool_names, "entity tool missing" - assert "entity_attach" in tool_names, "entity tool missing" - assert "get_facet" in tool_names, "facet tool missing" - assert "facet_news" in tool_names, "facet tool missing" - assert "send_message" in tool_names, "messaging tool missing" - assert "get_resource" in tool_names, "messaging tool missing" - - # Verify we have the expected number of tools (15 core tools after unification) - assert ( - len(tool_names) >= 15 - ), f"Expected at least 15 tools, got {len(tool_names)}" - - # Test 2: Call a tool (todo_list) - result = await session.call_tool( - "todo_list", arguments={"day": day, "facet": facet} - ) - - # Verify result structure - assert not result.isError, "Tool call returned error" - assert len(result.content) > 0, "Tool call returned no content" - - # Parse the result - result_text = result.content[0].text - result_data = json.loads(result_text) - - # Verify the todo was returned - assert result_data["day"] == day - assert result_data["facet"] == facet - assert "Integration test todo" in result_data["markdown"] - assert "1:" in result_data["markdown"], "Expected numbered output" - - # Test 3: List resources (may be empty if using templates) - await session.list_resources() - # Resources might be empty - the server uses resource templates - # which are dynamically resolved. This is expected behavior. - - # Test 4: Call another tool to verify different module - # Call search_journal to test search module - search_result = await session.call_tool( - "search_journal", - arguments={"query": "test", "limit": 1}, - ) - - assert not search_result.isError - search_data = json.loads(search_result.content[0].text) - assert "total" in search_data - assert "results" in search_data - assert "limit" in search_data - - # Test 5: Error handling - call tool with missing required argument - # FastMCP validates arguments and returns error result (not exception) - error_result = await session.call_tool( - "todo_list", - arguments={"day": day}, # Missing 'facet' argument - ) - # Server logs the error but returns a result with isError=True - assert error_result.isError, "Expected error result for missing argument" - - # Test 6: Successful tool call with nonexistent facet - # Note: todo_list creates empty todos file if it doesn't exist - # This is by design, not an error - result_nonexistent = await session.call_tool( - "todo_list", - arguments={"day": "20991231", "facet": "nonexistent-facet"}, - ) - - # Should succeed and return empty todos - assert not result_nonexistent.isError - data_nonexistent = json.loads(result_nonexistent.content[0].text) - assert "markdown" in data_nonexistent - assert "0:" in data_nonexistent["markdown"] # Empty todos - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_mcp_tool_packs_coverage(integration_journal_path): - """Verify all tool packs have their tools registered. - - This test ensures that the TOOL_PACKS dictionary in think/mcp.py - accurately reflects what's actually registered, which is important - for agents that use tool packs. - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - from think.mcp import TOOL_PACKS, mcp - - tools = await mcp.get_tools() - registered_tool_names = set(tools.keys()) - - # Check each tool pack - for pack_name, pack_tools in TOOL_PACKS.items(): - for tool_name in pack_tools: - assert ( - tool_name in registered_tool_names - ), f"Tool '{tool_name}' from pack '{pack_name}' not registered" - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_mcp_server_multiple_tool_calls(integration_journal_path): - """Test making multiple sequential tool calls to verify state handling. - - This ensures the server can handle multiple requests without issues. - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - # Setup test data - facet = "multi-test" - day = "20991231" - - facets_dir = integration_journal_path / "facets" / facet - facets_dir.mkdir(parents=True, exist_ok=True) - - facet_json = facets_dir / "facet.json" - facet_json.write_text( - json.dumps({"title": "Multi Test", "description": "Multiple calls test"}), - encoding="utf-8", - ) - - todos_dir = facets_dir / "todos" - todos_dir.mkdir(exist_ok=True) - - server_params = StdioServerParameters( - command="sol", - args=["mcp", "--transport", "stdio"], - env={**os.environ, "JOURNAL_PATH": str(integration_journal_path)}, - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Call 1: List empty todos - result1 = await session.call_tool( - "todo_list", arguments={"day": day, "facet": facet} - ) - data1 = json.loads(result1.content[0].text) - # Empty todos returns "0: (no todos)", not an error - assert "markdown" in data1 - - # Call 2: Search journal - result2 = await session.call_tool( - "search_journal", arguments={"query": "test", "limit": 5} - ) - data2 = json.loads(result2.content[0].text) - assert "results" in data2 - - # Call 3: Get facet info - result3 = await session.call_tool("get_facet", arguments={"facet": facet}) - data3 = json.loads(result3.content[0].text) - assert "facet" in data3 - assert data3["facet"] == facet - - # All three calls should succeed - assert not result1.isError - assert not result2.isError - assert not result3.isError - - -@pytest.mark.integration -@pytest.mark.asyncio -async def test_mcp_get_resource_tool(integration_journal_path): - """Test the get_resource tool can fetch journal resources. - - This verifies that the Context injection works correctly and - resources can be fetched via the tool wrapper. - """ - os.environ["JOURNAL_PATH"] = str(integration_journal_path) - - # Setup test data - create a todo resource to fetch - facet = "resource-test" - day = "20991231" - - facets_dir = integration_journal_path / "facets" / facet - facets_dir.mkdir(parents=True, exist_ok=True) - - facet_json = facets_dir / "facet.json" - facet_json.write_text( - json.dumps({"title": "Resource Test", "description": "Test get_resource"}), - encoding="utf-8", - ) - - todos_dir = facets_dir / "todos" - todos_dir.mkdir(exist_ok=True) - todo_file = todos_dir / f"{day}.jsonl" - # JSONL format: one JSON object per line - todo_file.write_text( - json.dumps({"text": "Test resource fetch"}) - + "\n" - + json.dumps({"text": "Already done", "completed": True}) - + "\n", - encoding="utf-8", - ) - - server_params = StdioServerParameters( - command="sol", - args=["mcp", "--transport", "stdio"], - env={**os.environ, "JOURNAL_PATH": str(integration_journal_path)}, - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Call get_resource to fetch the todo resource - result = await session.call_tool( - "get_resource", - arguments={"uri": f"journal://todo/{facet}/{day}"}, - ) - - # Verify success - assert not result.isError, f"get_resource failed: {result.content}" - assert len(result.content) > 0 - - # The result should contain the todo content - result_text = result.content[0].text - assert "Test resource fetch" in result_text - assert "Already done" in result_text diff --git a/tests/integration/test_openai_provider.py b/tests/integration/test_openai_provider.py index 3421be831..2057efa7a 100644 --- a/tests/integration/test_openai_provider.py +++ b/tests/integration/test_openai_provider.py @@ -49,7 +49,7 @@ def test_openai_provider_basic(): env["JOURNAL_PATH"] = journal_path env["OPENAI_API_KEY"] = api_key - # Create NDJSON input (no mcp_server_url = no MCP tools) + # Create NDJSON input (no tool config) ndjson_input = json.dumps( { "prompt": "what is 1+1? Just give me the number.", diff --git a/tests/test_agents_ndjson.py b/tests/test_agents_ndjson.py index 355831a7c..5dbfbcc50 100644 --- a/tests/test_agents_ndjson.py +++ b/tests/test_agents_ndjson.py @@ -99,8 +99,6 @@ def test_ndjson_single_request(mock_journal, monkeypatch, capsys): "name": "default", "model": GPT_5, "max_output_tokens": 100, - "mcp_server_url": "http://localhost:5175/mcp", - "tools": ["search_insights"], } ) @@ -141,22 +139,16 @@ def test_ndjson_multiple_requests(mock_journal, monkeypatch, capsys): { "prompt": "First question", "provider": "openai", - "mcp_server_url": "http://localhost:5175/mcp", - "tools": ["search_insights"], }, { "prompt": "Second question", "provider": "anthropic", "model": "claude-3", - "mcp_server_url": "http://localhost:5175/mcp", - "tools": ["search_insights"], }, { "prompt": "Third question", "provider": "google", "name": "technical", - "mcp_server_url": "http://localhost:5175/mcp", - "tools": ["search_insights"], }, ] @@ -194,9 +186,9 @@ def test_ndjson_multiple_requests(mock_journal, monkeypatch, capsys): def test_ndjson_invalid_json(mock_journal, monkeypatch, capsys): """Test handling of invalid JSON in NDJSON input.""" - ndjson_input = """{"prompt": "Valid request", "provider": "openai", "mcp_server_url": "http://localhost:5175/mcp", "tools": ["search_insights"]} + ndjson_input = """{"prompt": "Valid request", "provider": "openai"} not valid json -{"prompt": "Another valid request", "provider": "openai", "mcp_server_url": "http://localhost:5175/mcp", "tools": ["search_insights"]}""" +{"prompt": "Another valid request", "provider": "openai"}""" monkeypatch.setattr("sys.stdin", StringIO(ndjson_input)) @@ -230,7 +222,6 @@ def test_ndjson_missing_prompt(mock_journal, monkeypatch, capsys): { "provider": "openai", "model": GPT_5, - "tools": ["search_insights"], # Has tools, so needs prompt } ) @@ -258,9 +249,9 @@ def test_ndjson_missing_prompt(mock_journal, monkeypatch, capsys): def test_ndjson_empty_lines(mock_journal, monkeypatch, capsys): """Test that empty lines in NDJSON input are ignored.""" - ndjson_input = """{"prompt": "First", "provider": "openai", "tools": ["search_insights"]} + ndjson_input = """{"prompt": "First", "provider": "openai"} -{"prompt": "Second", "provider": "openai", "tools": ["search_insights"]} +{"prompt": "Second", "provider": "openai"} """ diff --git a/tests/test_anthropic.py b/tests/test_anthropic.py index 20df6b224..00ccbc7b9 100644 --- a/tests/test_anthropic.py +++ b/tests/test_anthropic.py @@ -222,7 +222,6 @@ def test_claude_main(monkeypatch, tmp_path, capsys): "prompt": "hello", "provider": "anthropic", "model": CLAUDE_SONNET_4, - "mcp_server_url": "http://localhost:5173/mcp", "tools": ["search_insights"], } ) @@ -267,7 +266,6 @@ def test_claude_outfile(monkeypatch, tmp_path, capsys): "prompt": "hello", "provider": "anthropic", "model": CLAUDE_SONNET_4, - "mcp_server_url": "http://localhost:5173/mcp", "tools": ["search_insights"], } ) @@ -316,7 +314,6 @@ def test_claude_thinking_events(monkeypatch, tmp_path, capsys): "prompt": "hello", "provider": "anthropic", "model": CLAUDE_SONNET_4, - "mcp_server_url": "http://localhost:5173/mcp", "tools": ["search_insights"], } ) @@ -360,7 +357,6 @@ def test_claude_redacted_thinking_events(monkeypatch, tmp_path, capsys): "prompt": "hello", "provider": "anthropic", "model": CLAUDE_SONNET_4, - "mcp_server_url": "http://localhost:5173/mcp", "tools": ["search_insights"], } ) @@ -402,7 +398,6 @@ def test_claude_outfile_error(monkeypatch, tmp_path, capsys): "prompt": "hello", "provider": "anthropic", "model": CLAUDE_SONNET_4, - "mcp_server_url": "http://localhost:5173/mcp", "tools": ["search_insights"], } ) diff --git a/tests/test_app_agents.py b/tests/test_app_agents.py index 9cb31d17b..319e7001a 100644 --- a/tests/test_app_agents.py +++ b/tests/test_app_agents.py @@ -127,7 +127,6 @@ def test_get_muse_configs_includes_system_agents(fixture_journal): assert "default" in agents assert agents["default"]["source"] == "system" assert "title" in agents["default"] - assert "tools" in agents["default"] assert "path" in agents["default"] diff --git a/tests/test_cortex.py b/tests/test_cortex.py index dff066c4a..b9b33d422 100644 --- a/tests/test_cortex.py +++ b/tests/test_cortex.py @@ -48,16 +48,10 @@ def mock_journal(tmp_path, monkeypatch): @pytest.fixture -def cortex_service(mock_journal, monkeypatch): +def cortex_service(mock_journal): """Create a CortexService instance for testing.""" from think.cortex import CortexService - monkeypatch.setattr(CortexService, "_start_mcp_server", lambda self: None) - monkeypatch.setattr( - CortexService, - "_wait_for_mcp_server", - lambda self, host, port, timeout=5.0: None, - ) return CortexService(str(mock_journal)) diff --git a/tests/test_entity_agents.py b/tests/test_entity_agents.py index 67dc6c242..52ecb8691 100644 --- a/tests/test_entity_agents.py +++ b/tests/test_entity_agents.py @@ -34,7 +34,6 @@ def test_entities_agent_config(fixture_journal): assert config.get("title") == "Entity Detector" assert config.get("schedule") == "daily" assert config.get("priority") == 55 - assert config.get("tools") == "journal, entities" assert config.get("multi_facet") is True @@ -54,7 +53,6 @@ def test_entities_review_agent_config(fixture_journal): assert config.get("title") == "Entity Reviewer" assert config.get("schedule") == "daily" assert config.get("priority") == 56 - assert config.get("tools") == "journal, entities" assert config.get("multi_facet") is True @@ -65,8 +63,8 @@ def test_entities_agent_instruction_content(fixture_journal): # Check for key sections in the agent prompt assert "Core Mission" in prompt - assert "entity_detect" in prompt - assert "entity_list" in prompt + assert "sol call entities detect" in prompt + assert "sol call entities list" in prompt assert "Knowledge Graphs" in prompt or "knowledge_graph" in prompt assert "day-specific context" in prompt.lower() @@ -78,8 +76,8 @@ def test_entities_review_agent_instruction_content(fixture_journal): # Check for key sections in the agent prompt assert "Core Mission" in prompt - assert "entity_attach" in prompt - assert "entity_list" in prompt + assert "sol call entities attach" in prompt + assert "sol call entities list" in prompt assert "3+" in prompt or "promotion" in prompt.lower() assert "description" in prompt.lower() diff --git a/tests/test_generators.py b/tests/test_generators.py index 7c5d41c01..d49166e15 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -163,7 +163,7 @@ def test_get_muse_configs_raises_on_missing_type_with_output(): prompt_path.unlink(missing_ok=True) -def test_get_muse_configs_raises_on_missing_type_with_tools(): +def test_get_muse_configs_allows_missing_type_with_tools(): muse = importlib.import_module("think.muse") stem = f"test_missing_type_tools_{uuid.uuid4().hex}" prompt_path = _write_temp_muse_prompt( @@ -171,10 +171,9 @@ def test_get_muse_configs_raises_on_missing_type_with_tools(): '{\n "schedule": "daily",\n "priority": 10,\n "tools": "journal"\n}', ) try: - with pytest.raises( - ValueError, match=rf"Prompt '{stem}'.*missing required 'type'" - ): - muse.get_muse_configs(include_disabled=True) + configs = muse.get_muse_configs(include_disabled=True) + assert stem in configs + assert configs[stem].get("type") is None finally: prompt_path.unlink(missing_ok=True) @@ -196,7 +195,7 @@ def test_get_muse_configs_raises_when_generate_missing_output(): prompt_path.unlink(missing_ok=True) -def test_get_muse_configs_raises_when_cogitate_missing_tools(): +def test_get_muse_configs_allows_cogitate_missing_tools(): muse = importlib.import_module("think.muse") stem = f"test_cogitate_missing_tools_{uuid.uuid4().hex}" prompt_path = _write_temp_muse_prompt( @@ -204,11 +203,9 @@ def test_get_muse_configs_raises_when_cogitate_missing_tools(): '{\n "type": "cogitate",\n "schedule": "daily",\n "priority": 10\n}', ) try: - with pytest.raises( - ValueError, - match=rf"Prompt '{stem}'.*type='cogitate'.*missing required 'tools'", - ): - muse.get_muse_configs(include_disabled=True) + configs = muse.get_muse_configs(include_disabled=True) + assert stem in configs + assert configs[stem].get("type") == "cogitate" finally: prompt_path.unlink(missing_ok=True) diff --git a/tests/test_google.py b/tests/test_google.py index ed75ee36c..012d95cdb 100644 --- a/tests/test_google.py +++ b/tests/test_google.py @@ -159,7 +159,6 @@ def test_google_mcp_error(monkeypatch, tmp_path, capsys): "prompt": "hello", "provider": "google", "model": GEMINI_FLASH, - "mcp_server_url": "http://localhost:6270/mcp", "tools": ["search_insights"], } ) diff --git a/tests/test_muse_cli.py b/tests/test_muse_cli.py index 0eb1d988e..107744d15 100644 --- a/tests/test_muse_cli.py +++ b/tests/test_muse_cli.py @@ -165,8 +165,8 @@ def test_list_prompts_output(capsys): assert "/activity.md" in output assert "/agents/flow.md" in output - # Tools column shows tools or dash - assert "journal, todo, entities" in output # default prompt + # Tools column is present (values may be '-' when tools are not configured) + assert "TOOLS" in output def test_list_prompts_schedule_filter(capsys): diff --git a/think/agents.py b/think/agents.py index 112348cf9..7e1cdf0ac 100644 --- a/think/agents.py +++ b/think/agents.py @@ -4,7 +4,7 @@ """Unified agent CLI for solstone. Spawned by cortex for all agent types: -- Tool-using agents (with MCP tools) +- Tool-using agents (with configured tools) - Generators (transcript analysis, no tools) Both paths share unified config preparation and execution flow. @@ -184,36 +184,6 @@ def parse_agent_events_to_turns(conversation_id: str) -> list: # ============================================================================= -def _expand_tools(tools_config: str) -> list[str]: - """Expand tool pack names to a list of tool names. - - Args: - tools_config: Comma-separated tool pack names (e.g., "default,entities") - - Returns: - List of unique tool names from all packs - """ - from think.mcp import get_tools - - pack_names = [p.strip() for p in tools_config.split(",") if p.strip()] - if not pack_names: - pack_names = ["default"] - - expanded: list[str] = [] - for pack in pack_names: - try: - for tool in get_tools(pack): - if tool not in expanded: - expanded.append(tool) - except KeyError: - LOG.warning(f"Invalid tool pack '{pack}', using default") - for tool in get_tools("default"): - if tool not in expanded: - expanded.append(tool) - - return expanded - - def _build_prompt_context( day: str | None, segment: str | None, span: list[str] | None ) -> dict[str, str]: @@ -322,7 +292,6 @@ def prepare_config(request: dict) -> dict: Config fields produced: - name: Agent name - provider, model: Resolved from context/request - - tools: Expanded tool list (if tool agent) - system_instruction: System prompt - user_instruction: Agent instruction from .md file - extra_context: Facets and context from instructions.now/day settings @@ -389,11 +358,6 @@ def prepare_config(request: dict) -> dict: config["provider"] = provider config["model"] = model - # Expand tools if string (pack name) - tools_config = config.get("tools") - if isinstance(tools_config, str): - config["tools"] = _expand_tools(tools_config) - # Check if disabled if config.get("disabled"): config["skip_reason"] = "disabled" @@ -584,7 +548,6 @@ def _build_dry_run_event(config: dict, before_values: dict) -> dict: if agent_type == "cogitate": event["extra_context"] = config.get("extra_context", "") - event["tools"] = config.get("tools", []) # Day-based fields if config.get("day"): diff --git a/think/call.py b/think/call.py index 9be7558e2..97660ce4c 100644 --- a/think/call.py +++ b/think/call.py @@ -4,12 +4,11 @@ """CLI interface for app tools via Typer. Provides ``sol call [args]`` as a human-friendly CLI that -parallels the MCP tool interface. Each app can contribute a ``call.py`` +parallels app tool functions. Each app can contribute a ``call.py`` module exporting a ``app = typer.Typer()`` instance whose commands are auto-discovered and mounted as sub-commands. -Discovery follows the same pattern as ``think.mcp._discover_app_tools``: -scan ``apps/*/call.py``, import, mount. +Discovery scans ``apps/*/call.py``, imports modules, and mounts subcommands. """ import importlib diff --git a/think/cortex.py b/think/cortex.py index a1e87f27b..7fd696246 100644 --- a/think/cortex.py +++ b/think/cortex.py @@ -21,7 +21,6 @@ import copy import json import logging import os -import socket import subprocess import sys import threading @@ -83,73 +82,10 @@ class CortexService: self.lock = threading.RLock() self.stop_event = threading.Event() self.shutdown_requested = threading.Event() - self.mcp_thread: Optional[threading.Thread] = None - self.mcp_server_url: Optional[str] = None # Callosum connection for receiving requests and broadcasting events self.callosum = CallosumConnection() - self._start_mcp_server() - - def _start_mcp_server(self) -> None: - """Start the FastMCP HTTP server in a background thread.""" - - if self.mcp_thread and self.mcp_thread.is_alive(): - return - - from think.mcp import mcp - from think.utils import find_available_port - - host = os.getenv("SOLSTONE_MCP_HOST", "127.0.0.1") - port_env = os.getenv("SOLSTONE_MCP_PORT", "0") - port = int(port_env) if port_env != "0" else find_available_port(host) - path = os.getenv("SOLSTONE_MCP_PATH", "/mcp") or "/mcp" - if not path.startswith("/"): - path = f"/{path}" - - self.mcp_server_url = f"http://{host}:{port}{path}" - - def _run_server() -> None: - try: - mcp.run( - transport="http", - host=host, - port=port, - path=path, - show_banner=False, - ) - except Exception: - self.logger.exception("MCP server thread exited unexpectedly") - - self.logger.info("Starting MCP server at %s", self.mcp_server_url) - self.mcp_thread = threading.Thread( - target=_run_server, - name="solstone-mcp-server", - daemon=True, - ) - self.mcp_thread.start() - self._wait_for_mcp_server(host, port) - - def _wait_for_mcp_server(self, host: str, port: int, timeout: float = 5.0) -> None: - """Block until MCP server socket accepts connections or timeout.""" - - deadline = time.time() + timeout - while time.time() < deadline: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(0.2) - try: - sock.connect((host, port)) - except OSError: - time.sleep(0.1) - continue - else: - self.logger.debug("MCP server ready at %s", self.mcp_server_url) - return - - self.logger.warning( - "Timed out waiting for MCP server at %s", self.mcp_server_url - ) - def _create_error_event( self, agent_id: str, @@ -285,10 +221,6 @@ class CortexService: with self.lock: self.agent_requests[agent_id] = request - # Inject MCP server URL - if self.mcp_server_url: - request["mcp_server_url"] = self.mcp_server_url - # Spawn agent process - it handles all validation/hydration try: self._spawn_subprocess( diff --git a/think/mcp.py b/think/mcp.py deleted file mode 100644 index 9ba16eee3..000000000 --- a/think/mcp.py +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""MCP server for solstone journal assistant. - -This module creates the FastMCP server instance and registers all tools and resources -from the think/tools/ and think/resources/ directories. - -Tool modules are located in think/tools/ and contain plain functions. -Resource handlers are located in think/resources/ and use @mcp.resource decorators. -""" - -from typing import Any, Callable, TypeVar - -from fastmcp import FastMCP - -# Create the MCP server instance -mcp = FastMCP("solstone") - -# Add annotation hints for all MCP tools -HINTS = {"readOnlyHint": True, "openWorldHint": False} - -F = TypeVar("F", bound=Callable[..., Any]) - - -def register_tool(*tool_args: Any, **tool_kwargs: Any) -> Callable[[F], F]: - """Register ``func`` as an MCP tool while keeping it directly callable.""" - - def decorator(func: F) -> F: - tool_obj = mcp.tool(*tool_args, **tool_kwargs)(func) - # Preserve FastMCP metadata so tests can call ``tool.fn`` while the - # module keeps the plain callable available. - setattr(func, "fn", getattr(tool_obj, "fn", func)) - return func - - return decorator - - -# Tool packs - logical groupings of tools -# Apps can extend existing packs or create new ones via TOOL_PACKS in their tools.py -TOOL_PACKS: dict[str, list[str]] = { - "journal": [ - "search_journal", - "get_events", - "get_facet", - "get_resource", - ], - "facets": [ - "facet_news", - ], - "apps": [], # Auto-populated with all app-discovered tools -} - - -# Import and register tool modules -# These imports trigger the registration of tools via the @register_tool decorator -from think.tools import facets, search - -# Register search tools -search_journal = register_tool(annotations=HINTS)(search.search_journal) -get_events = register_tool(annotations=HINTS)(search.get_events) - -# Register facet tools -get_facet = register_tool(annotations=HINTS)(facets.get_facet) -facet_news = register_tool(annotations=HINTS)(facets.facet_news) - -# Register resource tool (get_resource moved from messaging) -from think.tools.messaging import get_resource as get_resource_impl - -get_resource = register_tool(annotations=HINTS)(get_resource_impl) - -# Import resource modules - these self-register via @mcp.resource decorators -from think.resources import media, outputs, transcripts # noqa: F401 - - -# Phase 2: App-level tool discovery -def _discover_app_tools(): - """Discover and load tools from apps/*/tools.py. - - This function scans the apps/ directory for tools.py files and - dynamically registers any tools found there. This allows individual - apps to contribute their own MCP tools without modifying core code. - - Apps can define tools using the @register_tool decorator: - - # apps/myapp/tools.py - from think.mcp import register_tool, HINTS - - @register_tool(annotations=HINTS) - def my_tool(arg: str) -> dict: - return {"result": "..."} - - Apps can also declare pack membership via a module-level TOOL_PACKS dict: - - # apps/myapp/tools.py - TOOL_PACKS = { - "journal": ["my_tool"], # Add to existing pack - "myapp": ["my_tool"], # Create new pack - } - - All app tools are automatically added to the "apps" pack. Tools are - registered when the module is imported. If an app's tools.py file - fails to import, the error is logged but doesn't prevent other apps - from loading or the server from starting. - """ - import importlib - import logging - from pathlib import Path - - logger = logging.getLogger(__name__) - apps_dir = Path(__file__).parent.parent / "apps" - - if not apps_dir.exists(): - logger.debug("No apps/ directory found, skipping app tool discovery") - return - - discovered_count = 0 - total_tools = 0 - - for app_dir in sorted(apps_dir.iterdir()): - # Skip non-directories and private directories - if not app_dir.is_dir() or app_dir.name.startswith("_"): - continue - - tools_file = app_dir / "tools.py" - if not tools_file.exists(): - continue - - app_name = app_dir.name - - try: - # Import triggers @register_tool decorators - module_name = f"apps.{app_name}.tools" - module = importlib.import_module(module_name) - - # Collect tool names (functions decorated with @register_tool have .fn) - app_tools = [ - name - for name, obj in vars(module).items() - if callable(obj) and hasattr(obj, "fn") and not name.startswith("_") - ] - - # Add all app tools to the "apps" pack - TOOL_PACKS["apps"].extend(app_tools) - total_tools += len(app_tools) - - # Merge app-declared pack memberships - if hasattr(module, "TOOL_PACKS"): - for pack, tools in module.TOOL_PACKS.items(): - if pack not in TOOL_PACKS: - TOOL_PACKS[pack] = [] - TOOL_PACKS[pack].extend(tools) - logger.debug(f"App '{app_name}' added {tools} to pack '{pack}'") - - discovered_count += 1 - logger.info(f"Loaded {len(app_tools)} MCP tool(s) from app: {app_name}") - except Exception as e: - # Gracefully handle errors - don't break server startup - logger.error( - f"Failed to load tools from app '{app_name}': {e}", exc_info=True - ) - - if discovered_count > 0: - logger.info(f"Discovered {total_tools} tool(s) from {discovered_count} app(s)") - - -_discover_app_tools() - - -def get_tools(pack: str = "default") -> list[str]: - """Get list of tool names for a given pack. - - Args: - pack: Name of the tool pack (default: "default" which maps to "journal") - - Returns: - List of tool names in the pack - - Raises: - KeyError: If pack doesn't exist - """ - # "default" is an alias for "journal" - if pack == "default": - pack = "journal" - - if pack not in TOOL_PACKS: - raise KeyError( - f"Unknown tool pack '{pack}'. Available: {list(TOOL_PACKS.keys())}" - ) - return TOOL_PACKS[pack] - - -def main() -> None: - """Run the MCP server using the requested transport.""" - import argparse - - from think.utils import setup_cli - - parser = argparse.ArgumentParser(description="solstone MCP Tools Server") - parser.add_argument( - "--transport", - choices=["stdio", "http"], - default="stdio", - help="Transport method: stdio (default) or http", - ) - parser.add_argument( - "--port", - type=int, - default=6270, - help="Port to bind to for HTTP transport (default: 6270; cortex uses dynamic port)", - ) - parser.add_argument( - "--path", default="/mcp", help="HTTP path for MCP endpoints (default: /mcp)" - ) - - args = setup_cli(parser) - - if args.transport == "http": - mcp.run( - transport="http", - host="127.0.0.1", - port=args.port, - path=args.path, - show_banner=False, - ) - else: - # default stdio transport - mcp.run(show_banner=False) - - -if __name__ == "__main__": - main() diff --git a/think/muse.py b/think/muse.py index 09b017fb7..6c0fa8230 100644 --- a/think/muse.py +++ b/think/muse.py @@ -248,10 +248,9 @@ def get_muse_configs( f"All prompts with 'schedule' must declare an explicit priority." ) - # Validate: prompts with tools/output must have consistent explicit type + # Validate: prompts with output must have consistent explicit type valid_types = {"generate", "cogitate"} for key, info in configs.items(): - tools_present = "tools" in info output_present = "output" in info config_type = info.get("type") @@ -261,12 +260,12 @@ def get_muse_configs( "Expected 'generate' or 'cogitate'." ) - if not tools_present and not output_present and config_type is None: + if not output_present and config_type is None: continue if config_type is None: raise ValueError( - f"Prompt '{key}' has tools/output but is missing required 'type' field." + f"Prompt '{key}' has output but is missing required 'type' field." ) if config_type == "generate" and not output_present: diff --git a/think/providers/__init__.py b/think/providers/__init__.py index 781c47435..5d260845a 100644 --- a/think/providers/__init__.py +++ b/think/providers/__init__.py @@ -8,7 +8,7 @@ and agent execution. Each provider module exposes: - run_generate(): Sync text generation, returns GenerateResult - run_agenerate(): Async text generation, returns GenerateResult -- run_cogitate(): Tool-calling execution with MCP integration +- run_cogitate(): Tool-calling execution with event streaming GenerateResult is a TypedDict with: text, usage, finish_reason, thinking. The wrapper functions in think.models handle token logging and JSON validation. diff --git a/think/providers/google.py b/think/providers/google.py index a429d2d74..f891e5bec 100644 --- a/think/providers/google.py +++ b/think/providers/google.py @@ -568,7 +568,7 @@ async def run_cogitate( config: dict[str, Any], on_event: Callable[[dict], None] | None = None, ) -> str: - """Run a prompt with MCP tool-calling support via Google Gemini. + """Run a prompt with tool-calling support via Google Gemini. Args: config: Complete configuration dictionary including prompt, system_instruction, diff --git a/think/resources/__init__.py b/think/resources/__init__.py deleted file mode 100644 index f2c5b4f39..000000000 --- a/think/resources/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc diff --git a/think/resources/media.py b/think/resources/media.py deleted file mode 100644 index 80e8ed311..000000000 --- a/think/resources/media.py +++ /dev/null @@ -1,40 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""MCP resource handlers for media.""" - -from pathlib import Path - -from fastmcp.resources import FileResource - -from think.mcp import mcp -from think.utils import get_journal, get_raw_file - - -@mcp.resource("journal://media/{day}/{name}") -def get_media(day: str, name: str) -> FileResource: - """Return a raw FLAC or PNG file referenced by a transcript. - - Parameters - ---------- - day: - Day folder in ``YYYYMMDD`` format. - name: - Transcript JSON filename such as ``HHMMSS_audio.json`` or - ``HHMMSS_monitor_1_diff.json``. - - Returns - ------- - FileResource - Resource pointing to the raw media file referenced by ``name``. - """ - - rel_path, mime, _ = get_raw_file(day, name) - abs_path = Path(get_journal()) / day / rel_path - return FileResource( - uri=f"journal://media/{day}/{name}", - name=f"Media: {name}", - description=f"Raw media file from {day}", - mime_type=mime, - path=abs_path, - ) diff --git a/think/resources/outputs.py b/think/resources/outputs.py deleted file mode 100644 index d5637a9a9..000000000 --- a/think/resources/outputs.py +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""MCP resource handlers for agent outputs.""" - -from pathlib import Path - -from fastmcp.resources import TextResource - -from think.mcp import mcp -from think.utils import get_journal - - -@mcp.resource("journal://agents/{day}/{topic}") -def get_agent_output(day: str, topic: str) -> TextResource: - """Return the markdown output for a topic.""" - md_path = Path(get_journal()) / day / "agents" / f"{topic}.md" - - if not md_path.is_file(): - text = f"Topic '{topic}' not found for day {day}" - else: - text = md_path.read_text(encoding="utf-8") - - return TextResource( - uri=f"journal://agents/{day}/{topic}", - name=f"Output: {topic} ({day})", - description=f"Agent output on {topic} from {day}", - mime_type="text/markdown", - text=text, - ) diff --git a/think/resources/transcripts.py b/think/resources/transcripts.py deleted file mode 100644 index 8374fc9c9..000000000 --- a/think/resources/transcripts.py +++ /dev/null @@ -1,164 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""MCP resource handlers for transcripts.""" - -from datetime import datetime, timedelta - -from fastmcp.resources import TextResource - -from think.cluster import cluster_range -from think.mcp import mcp - - -def _get_transcript_resource( - mode: str, day: str, time: str, length: str -) -> TextResource: - """Shared handler for all transcript resource modes. - - Args: - mode: Transcript mode - "full", "audio", "screen", or "summary" - day: Day in YYYYMMDD format - time: Start time in HHMMSS format - length: Length in minutes for the time range - """ - try: - # Parse the length as minutes and convert to end time - length_minutes = int(length) - - # Validate maximum length to prevent context overload - if length_minutes > 120: - error_content = f"# Error\n\nRequested {length_minutes} minutes exceeds the maximum of 120 minutes per call to minimize context overload. Please request a shorter time range." - return TextResource( - uri=f"journal://transcripts/{mode}/{day}/{time}/{length}", - name=f"Transcripts Error ({mode}): {day} {time} ({length}min)", - description="Error: Requested length exceeds maximum", - mime_type="text/markdown", - text=error_content, - ) - - # Parse start time - start_dt = datetime.strptime(f"{day}{time}", "%Y%m%d%H%M%S") - # Calculate end time - end_dt = start_dt + timedelta(minutes=length_minutes) - end_time = end_dt.strftime("%H%M%S") - - # Configure cluster_range based on mode - if mode == "full": - markdown_content = cluster_range( - day=day, - start=time, - end=end_time, - sources={"audio": True, "screen": True, "agents": False}, - ) - description = f"Raw audio and screencast transcripts from {day} at {time} for {length} minutes" - elif mode == "audio": - markdown_content = cluster_range( - day=day, - start=time, - end=end_time, - sources={"audio": True, "screen": False, "agents": False}, - ) - description = ( - f"Raw audio transcripts from {day} at {time} for {length} minutes" - ) - elif mode == "screen": - markdown_content = cluster_range( - day=day, - start=time, - end=end_time, - sources={"audio": False, "screen": True, "agents": False}, - ) - description = ( - f"Raw screencast transcripts from {day} at {time} for {length} minutes" - ) - elif mode == "summary": - markdown_content = cluster_range( - day=day, - start=time, - end=end_time, - sources={"audio": False, "screen": False, "agents": True}, - ) - description = ( - f"AI-generated summaries from {day} at {time} for {length} minutes" - ) - else: - raise ValueError(f"Invalid transcript mode: {mode}") - - return TextResource( - uri=f"journal://transcripts/{mode}/{day}/{time}/{length}", - name=f"Transcripts ({mode}): {day} {time} ({length}min)", - description=description, - mime_type="text/markdown", - text=markdown_content, - ) - - except Exception as e: - error_content = f"# Error\n\nFailed to generate {mode} transcripts for {day} {time} ({length}min): {str(e)}" - return TextResource( - uri=f"journal://transcripts/{mode}/{day}/{time}/{length}", - name=f"Transcripts Error ({mode}): {day} {time} ({length}min)", - description=f"Error generating {mode} transcripts", - mime_type="text/markdown", - text=error_content, - ) - - -@mcp.resource("journal://transcripts/full/{day}/{time}/{length}") -def get_transcripts_full(day: str, time: str, length: str) -> TextResource: - """Return formatted raw audio and screencast transcripts. - - Provides both spoken word transcripts and frame-level screen activity - transcriptions. Use this when you need the complete raw capture data. - - Args: - day: Day in YYYYMMDD format - time: Start time in HHMMSS format - length: Length in minutes for the time range - """ - return _get_transcript_resource("full", day, time, length) - - -@mcp.resource("journal://transcripts/audio/{day}/{time}/{length}") -def get_transcripts_audio(day: str, time: str, length: str) -> TextResource: - """Return formatted raw audio transcripts only. - - Provides spoken word transcripts from microphone and system audio capture. - Use this when you only need what was said, without screen activity. - - Args: - day: Day in YYYYMMDD format - time: Start time in HHMMSS format - length: Length in minutes for the time range - """ - return _get_transcript_resource("audio", day, time, length) - - -@mcp.resource("journal://transcripts/screen/{day}/{time}/{length}") -def get_transcripts_screen(day: str, time: str, length: str) -> TextResource: - """Return formatted raw screencast transcripts only. - - Provides frame-level screen activity transcriptions showing what appeared - on screen. Use this when you only need visual activity, without audio. - - Args: - day: Day in YYYYMMDD format - time: Start time in HHMMSS format - length: Length in minutes for the time range - """ - return _get_transcript_resource("screen", day, time, length) - - -@mcp.resource("journal://transcripts/summary/{day}/{time}/{length}") -def get_transcripts_summary(day: str, time: str, length: str) -> TextResource: - """Return AI-generated summaries and insights. - - Provides processed summaries of screen activity and other observations. - Use this for high-level understanding rather than raw transcript data. - - Args: - day: Day in YYYYMMDD format - time: Start time in HHMMSS format - length: Length in minutes for the time range - """ - return _get_transcript_resource("summary", day, time, length) diff --git a/think/tools/call.py b/think/tools/call.py index 7878c3f02..017ab1476 100644 --- a/think/tools/call.py +++ b/think/tools/call.py @@ -4,7 +4,7 @@ """CLI commands for journal search and browsing. Provides human-friendly CLI access to journal operations, paralleling the -MCP tools in ``think/tools/search.py`` and ``think/tools/facets.py`` but +tool functions in ``think/tools/search.py`` and ``think/tools/facets.py`` but optimized for terminal use. Mounted by ``think.call`` as ``sol call journal ...``. diff --git a/think/tools/facets.py b/think/tools/facets.py index 35bb69407..b0a943186 100644 --- a/think/tools/facets.py +++ b/think/tools/facets.py @@ -1,10 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""MCP tools for facet management. +"""Tool functions for facet management. -Note: These functions are registered as MCP tools by think/mcp.py -They can also be imported and called directly for testing or internal use. +These functions can be imported and called directly from agent workflows, +tests, or other internal modules. """ from pathlib import Path diff --git a/think/tools/messaging.py b/think/tools/messaging.py deleted file mode 100644 index 41c792b34..000000000 --- a/think/tools/messaging.py +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""MCP tools for resource operations. - -Note: These functions are registered as MCP tools by think/mcp.py -They can also be imported and called directly for testing or internal use. -""" - -import base64 - - -async def get_resource(uri: str) -> object: - """Return the contents of a journal resource. - - Many MCP clients cannot read ``journal://`` resources directly. This tool - acts as a wrapper around the server resources so they can be fetched via a - normal tool call. - - The following resource types are supported: - - - ``journal://insight/{day}/{topic}`` — markdown topic insights - - ``journal://transcripts/full/{day}/{time}/{length}`` — full transcripts (audio + raw screen) - - ``journal://transcripts/audio/{day}/{time}/{length}`` — audio transcripts only - - ``journal://transcripts/screen/{day}/{time}/{length}`` — screen summaries only - - ``journal://media/{day}/{name}`` — raw FLAC or PNG media files - - ``journal://todo/{facet}/{day}`` — facet-scoped todo checklist file - - Args: - uri: Resource URI to fetch. - - Returns: - Base64-encoded string for binary media, or a plain string for - text resources. - """ - # Import here to avoid circular import at module load time - from think.mcp import mcp - - try: - # Use the resource manager directly - bypasses Context initialization issues - result = await mcp._resource_manager.read_resource(uri) - - # read_resource returns str for text, bytes for binary - if isinstance(result, bytes): - return base64.b64encode(result).decode("ascii") - return result - except Exception as exc: - return {"error": f"Failed to fetch resource: {exc}"} diff --git a/think/tools/search.py b/think/tools/search.py index 5edf8f2ba..6e193b5c3 100644 --- a/think/tools/search.py +++ b/think/tools/search.py @@ -1,10 +1,10 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""MCP tools for search operations. +"""Tool functions for search operations. -Note: These functions are registered as MCP tools by think/mcp.py -They can also be imported and called directly for testing or internal use. +These functions can be imported and called directly from agent workflows, +tests, or other internal modules. """ from datetime import datetime, timedelta