kash-shell 0.3.8__py3-none-any.whl → 0.3.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kash/actions/__init__.py +4 -4
- kash/actions/core/markdownify.py +5 -2
- kash/actions/core/readability.py +5 -2
- kash/actions/core/render_as_html.py +18 -0
- kash/actions/core/webpage_config.py +12 -4
- kash/commands/__init__.py +8 -20
- kash/commands/base/basic_file_commands.py +15 -0
- kash/commands/base/debug_commands.py +15 -2
- kash/commands/base/general_commands.py +27 -18
- kash/commands/base/logs_commands.py +1 -4
- kash/commands/base/model_commands.py +8 -8
- kash/commands/base/search_command.py +3 -2
- kash/commands/base/show_command.py +5 -3
- kash/commands/extras/parse_uv_lock.py +186 -0
- kash/commands/help/doc_commands.py +2 -31
- kash/commands/help/welcome.py +33 -0
- kash/commands/workspace/selection_commands.py +11 -6
- kash/commands/workspace/workspace_commands.py +19 -16
- kash/config/colors.py +2 -0
- kash/config/env_settings.py +72 -0
- kash/config/init.py +2 -2
- kash/config/logger.py +61 -59
- kash/config/logger_basic.py +12 -5
- kash/config/server_config.py +6 -6
- kash/config/settings.py +117 -67
- kash/config/setup.py +35 -9
- kash/config/suppress_warnings.py +30 -12
- kash/config/text_styles.py +3 -13
- kash/docs/load_api_docs.py +2 -1
- kash/docs/markdown/topics/a2_installation.md +7 -3
- kash/docs/markdown/topics/a3_getting_started.md +3 -2
- kash/docs/markdown/warning.md +3 -8
- kash/docs/markdown/welcome.md +4 -0
- kash/docs_base/load_recipe_snippets.py +1 -1
- kash/docs_base/recipes/{general_system_commands.ksh → general_system_commands.sh} +1 -1
- kash/{concepts → embeddings}/cosine.py +2 -1
- kash/embeddings/text_similarity.py +57 -0
- kash/exec/__init__.py +20 -3
- kash/exec/action_decorators.py +18 -4
- kash/exec/action_exec.py +41 -23
- kash/exec/action_registry.py +13 -48
- kash/exec/command_registry.py +2 -1
- kash/exec/fetch_url_metadata.py +4 -6
- kash/exec/importing.py +56 -0
- kash/exec/llm_transforms.py +6 -6
- kash/exec/precondition_registry.py +2 -1
- kash/exec/preconditions.py +16 -1
- kash/exec/shell_callable_action.py +33 -19
- kash/file_storage/file_store.py +23 -14
- kash/file_storage/item_file_format.py +13 -3
- kash/file_storage/metadata_dirs.py +11 -2
- kash/help/assistant.py +2 -2
- kash/help/assistant_instructions.py +2 -1
- kash/help/help_embeddings.py +2 -2
- kash/help/help_printing.py +14 -10
- kash/help/tldr_help.py +5 -3
- kash/llm_utils/clean_headings.py +1 -1
- kash/llm_utils/llm_api_keys.py +4 -4
- kash/llm_utils/llm_completion.py +2 -2
- kash/llm_utils/llm_features.py +68 -0
- kash/llm_utils/llm_messages.py +1 -2
- kash/llm_utils/llm_names.py +1 -1
- kash/llm_utils/llms.py +17 -12
- kash/local_server/__init__.py +5 -2
- kash/local_server/local_server.py +56 -46
- kash/local_server/local_server_commands.py +15 -15
- kash/local_server/local_server_routes.py +2 -2
- kash/local_server/local_url_formatters.py +1 -1
- kash/mcp/__init__.py +5 -2
- kash/mcp/mcp_cli.py +54 -17
- kash/mcp/mcp_server_commands.py +5 -6
- kash/mcp/mcp_server_routes.py +14 -11
- kash/mcp/mcp_server_sse.py +61 -34
- kash/mcp/mcp_server_stdio.py +0 -8
- kash/media_base/audio_processing.py +81 -7
- kash/media_base/media_cache.py +18 -18
- kash/media_base/media_services.py +1 -1
- kash/media_base/media_tools.py +6 -6
- kash/media_base/services/local_file_media.py +2 -2
- kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -109
- kash/media_base/transcription_format.py +73 -0
- kash/media_base/transcription_whisper.py +38 -0
- kash/model/__init__.py +73 -5
- kash/model/actions_model.py +38 -4
- kash/model/concept_model.py +30 -0
- kash/model/items_model.py +56 -13
- kash/model/params_model.py +24 -0
- kash/shell/completions/completion_scoring.py +37 -5
- kash/shell/output/kerm_codes.py +1 -2
- kash/shell/output/shell_formatting.py +14 -4
- kash/shell/shell_main.py +2 -2
- kash/shell/utils/exception_printing.py +6 -0
- kash/shell/utils/native_utils.py +26 -20
- kash/text_handling/custom_sliding_transforms.py +12 -4
- kash/text_handling/doc_normalization.py +6 -2
- kash/text_handling/markdown_render.py +117 -0
- kash/text_handling/markdown_utils.py +204 -0
- kash/utils/common/import_utils.py +12 -3
- kash/utils/common/type_utils.py +0 -29
- kash/utils/common/url.py +80 -28
- kash/utils/errors.py +6 -0
- kash/utils/file_utils/{dir_size.py → dir_info.py} +25 -4
- kash/utils/file_utils/file_ext.py +2 -3
- kash/utils/file_utils/file_formats.py +28 -2
- kash/utils/file_utils/file_formats_model.py +50 -19
- kash/utils/file_utils/filename_parsing.py +10 -4
- kash/web_content/dir_store.py +1 -2
- kash/web_content/file_cache_utils.py +37 -10
- kash/web_content/file_processing.py +68 -0
- kash/web_content/local_file_cache.py +12 -9
- kash/web_content/web_extract.py +8 -3
- kash/web_content/web_fetch.py +12 -4
- kash/web_gen/tabbed_webpage.py +5 -2
- kash/web_gen/templates/base_styles.css.jinja +120 -14
- kash/web_gen/templates/base_webpage.html.jinja +60 -13
- kash/web_gen/templates/content_styles.css.jinja +4 -2
- kash/web_gen/templates/item_view.html.jinja +2 -2
- kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
- kash/workspaces/__init__.py +15 -2
- kash/workspaces/selections.py +18 -3
- kash/workspaces/source_items.py +4 -2
- kash/workspaces/workspace_output.py +11 -4
- kash/workspaces/workspaces.py +5 -11
- kash/xonsh_custom/command_nl_utils.py +40 -19
- kash/xonsh_custom/custom_shell.py +44 -12
- kash/xonsh_custom/customize_prompt.py +39 -21
- kash/xonsh_custom/load_into_xonsh.py +26 -27
- kash/xonsh_custom/shell_load_commands.py +2 -2
- kash/xonsh_custom/xonsh_completers.py +2 -249
- kash/xonsh_custom/xonsh_keybindings.py +282 -0
- kash/xonsh_custom/xonsh_modern_tools.py +3 -3
- kash/xontrib/kash_extension.py +5 -6
- {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/METADATA +26 -12
- {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/RECORD +140 -140
- {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +1 -1
- kash/concepts/concept_formats.py +0 -23
- kash/concepts/text_similarity.py +0 -112
- kash/shell/clideps/api_keys.py +0 -99
- kash/shell/clideps/dotenv_setup.py +0 -114
- kash/shell/clideps/dotenv_utils.py +0 -89
- kash/shell/clideps/pkg_deps.py +0 -232
- kash/shell/clideps/platforms.py +0 -11
- kash/shell/clideps/terminal_features.py +0 -56
- kash/shell/utils/osc_utils.py +0 -95
- kash/shell/utils/terminal_images.py +0 -133
- kash/text_handling/markdown_util.py +0 -167
- kash/utils/common/atomic_var.py +0 -158
- kash/utils/common/string_replace.py +0 -93
- kash/utils/common/string_template.py +0 -101
- /kash/docs_base/recipes/{python_dev_commands.ksh → python_dev_commands.sh} +0 -0
- /kash/docs_base/recipes/{tldr_standard_commands.ksh → tldr_standard_commands.sh} +0 -0
- /kash/{concepts → embeddings}/embeddings.py +0 -0
- {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from kash.config.logger import get_logger
|
|
2
|
-
from kash.config.settings import global_settings
|
|
2
|
+
from kash.config.settings import global_settings
|
|
3
3
|
from kash.exec import kash_command
|
|
4
|
-
from kash.local_server.local_server import LOCAL_SERVER_NAME
|
|
5
4
|
from kash.local_server.local_url_formatters import enable_local_urls
|
|
6
5
|
from kash.shell.utils.native_utils import tail_file
|
|
7
6
|
from kash.utils.errors import InvalidState
|
|
@@ -10,49 +9,50 @@ log = get_logger(__name__)
|
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
@kash_command
|
|
13
|
-
def
|
|
12
|
+
def start_ui_server() -> None:
|
|
14
13
|
"""
|
|
15
|
-
Start the kash local server. This exposes local info on files and commands so
|
|
14
|
+
Start the kash local ui server. This exposes local info on files and commands so
|
|
15
|
+
they can be displayed in your terminal, if it supports OSC 8 links.
|
|
16
16
|
Note this is most useful for the Kerm terminal, which shows links as
|
|
17
17
|
tooltips.
|
|
18
18
|
"""
|
|
19
|
-
from kash.local_server.local_server import
|
|
19
|
+
from kash.local_server.local_server import start_ui_server
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
start_ui_server()
|
|
22
22
|
enable_local_urls(True)
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
@kash_command
|
|
26
|
-
def
|
|
26
|
+
def stop_ui_server() -> None:
|
|
27
27
|
"""
|
|
28
28
|
Stop the kash local server.
|
|
29
29
|
"""
|
|
30
|
-
from kash.local_server.local_server import
|
|
30
|
+
from kash.local_server.local_server import stop_ui_server
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
stop_ui_server()
|
|
33
33
|
enable_local_urls(False)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
@kash_command
|
|
37
|
-
def
|
|
37
|
+
def restart_ui_server() -> None:
|
|
38
38
|
"""
|
|
39
39
|
Restart the kash local server.
|
|
40
40
|
"""
|
|
41
|
-
from kash.local_server.local_server import
|
|
41
|
+
from kash.local_server.local_server import restart_ui_server
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
restart_ui_server()
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
@kash_command
|
|
47
47
|
def local_server_logs(follow: bool = False) -> None:
|
|
48
48
|
"""
|
|
49
|
-
Show the logs from the kash local
|
|
49
|
+
Show the logs from the kash local (UI and MCP) servers.
|
|
50
50
|
|
|
51
51
|
:param follow: Follow the file as it grows.
|
|
52
52
|
"""
|
|
53
|
-
log_path =
|
|
53
|
+
log_path = global_settings().local_server_log_path
|
|
54
54
|
if not log_path.exists():
|
|
55
55
|
raise InvalidState(
|
|
56
|
-
f"Local server log not found (forgot to run `
|
|
56
|
+
f"Local ui server log not found (forgot to run `start_ui_server`?): {log_path}"
|
|
57
57
|
)
|
|
58
58
|
tail_file(log_path, follow=follow)
|
|
@@ -43,11 +43,11 @@ def format_local_url(route_path: str, **params: str | None) -> str:
|
|
|
43
43
|
"""
|
|
44
44
|
URL to content on the local server.
|
|
45
45
|
"""
|
|
46
|
-
from kash.local_server.local_server import
|
|
46
|
+
from kash.local_server.local_server import UI_SERVER_HOST
|
|
47
47
|
|
|
48
48
|
settings = global_settings()
|
|
49
49
|
route_path = route_path.strip("/")
|
|
50
|
-
url = f"http://{
|
|
50
|
+
url = f"http://{UI_SERVER_HOST}:{settings.local_server_port}/{route_path}"
|
|
51
51
|
if params:
|
|
52
52
|
query_params = {k: v for k, v in params.items() if v is not None}
|
|
53
53
|
if query_params:
|
|
@@ -4,13 +4,13 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from rich.style import Style
|
|
6
6
|
from rich.text import Text
|
|
7
|
+
from strif import AtomicVar
|
|
7
8
|
from typing_extensions import override
|
|
8
9
|
|
|
9
10
|
from kash.config.logger import get_logger
|
|
10
11
|
from kash.config.text_styles import STYLE_HINT
|
|
11
12
|
from kash.model.paths_model import StorePath
|
|
12
13
|
from kash.shell.output.kerm_codes import KriLink, TextTooltip, UIAction, UIActionType
|
|
13
|
-
from kash.utils.common.atomic_var import AtomicVar
|
|
14
14
|
from kash.utils.common.format_utils import fmt_loc
|
|
15
15
|
from kash.utils.errors import InvalidState
|
|
16
16
|
from kash.workspaces import current_ws
|
kash/mcp/__init__.py
CHANGED
kash/mcp/mcp_cli.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Command-line launcher for running an MCP server.
|
|
3
|
-
standalone mode, with all kash tools exposed. But can be run in SSE standalone
|
|
2
|
+
Command-line launcher for running an MCP server. With no options, by default runs in
|
|
3
|
+
stdio standalone mode, with all kash tools exposed. But can be run in SSE standalone
|
|
4
4
|
mode or as a stdio proxy to another SSE server.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -9,27 +9,24 @@ import logging
|
|
|
9
9
|
import os
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
from kash.config.
|
|
13
|
-
from kash.config.
|
|
14
|
-
from kash.config.setup import setup
|
|
15
|
-
from kash.mcp.mcp_main import McpMode, run_mcp_server
|
|
16
|
-
from kash.mcp.mcp_server_sse import MCP_LOG_PREFIX
|
|
12
|
+
from kash.config.settings import DEFAULT_MCP_SERVER_PORT, LogLevel, global_settings
|
|
13
|
+
from kash.config.setup import kash_setup
|
|
17
14
|
from kash.shell.utils.argparse_utils import WrappedColorFormatter
|
|
18
15
|
from kash.shell.version import get_version
|
|
19
|
-
from kash.workspaces.workspaces import Workspace, get_ws, global_ws_dir
|
|
20
16
|
|
|
21
17
|
__version__ = get_version()
|
|
22
18
|
|
|
23
19
|
DEFAULT_PROXY_URL = f"http://localhost:{DEFAULT_MCP_SERVER_PORT}/sse"
|
|
24
20
|
|
|
25
|
-
|
|
21
|
+
MCP_CLI_LOG_PATH = global_settings().system_logs_dir / "mcp_cli.log"
|
|
26
22
|
|
|
27
|
-
basic_logging_setup(LOG_PATH, LogLevel.info)
|
|
28
23
|
|
|
29
24
|
log = logging.getLogger()
|
|
30
25
|
|
|
31
26
|
|
|
32
27
|
def build_parser():
|
|
28
|
+
from kash.workspaces.workspaces import global_ws_dir
|
|
29
|
+
|
|
33
30
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=WrappedColorFormatter)
|
|
34
31
|
parser.add_argument(
|
|
35
32
|
"--version",
|
|
@@ -54,21 +51,50 @@ def build_parser():
|
|
|
54
51
|
f"{DEFAULT_PROXY_URL}"
|
|
55
52
|
),
|
|
56
53
|
)
|
|
57
|
-
parser.add_argument(
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--sse", action="store_true", help="Run in SSE standalone mode instead of stdio"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--list_tools",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="List tools that will be available",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--tool_help",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Show full help for each tool",
|
|
66
|
+
)
|
|
67
|
+
|
|
58
68
|
return parser
|
|
59
69
|
|
|
60
70
|
|
|
61
|
-
def
|
|
62
|
-
|
|
71
|
+
def show_tool_info(full_help: bool):
|
|
72
|
+
from kash.exec.action_registry import get_all_actions_defaults
|
|
73
|
+
from kash.help.help_printing import print_action_help
|
|
74
|
+
from kash.mcp.mcp_server_routes import get_published_mcp_tools, publish_mcp_tools
|
|
75
|
+
from kash.shell.output.shell_output import cprint
|
|
63
76
|
|
|
64
|
-
|
|
77
|
+
publish_mcp_tools()
|
|
78
|
+
tools = get_published_mcp_tools()
|
|
79
|
+
cprint("Actions available as MCP tools:")
|
|
80
|
+
cprint()
|
|
81
|
+
actions = get_all_actions_defaults()
|
|
82
|
+
for tool in tools:
|
|
83
|
+
action = actions[tool]
|
|
84
|
+
print_action_help(
|
|
85
|
+
action, verbose=False, include_options=full_help, include_precondition=full_help
|
|
86
|
+
)
|
|
87
|
+
cprint()
|
|
65
88
|
|
|
66
|
-
setup(rich_logging=False)
|
|
67
89
|
|
|
68
|
-
|
|
90
|
+
def run_server(args: argparse.Namespace):
|
|
91
|
+
from kash.mcp.mcp_main import McpMode, run_mcp_server
|
|
92
|
+
from kash.workspaces.workspaces import Workspace, get_ws
|
|
93
|
+
|
|
94
|
+
log.warning("kash MCP CLI started, logging to: %s", MCP_CLI_LOG_PATH)
|
|
69
95
|
log.warning("Current working directory: %s", Path(".").resolve())
|
|
70
96
|
|
|
71
|
-
ws: Workspace = get_ws(name_or_path=
|
|
97
|
+
ws: Workspace = get_ws(name_or_path=Path(args.workspace), auto_init=True)
|
|
72
98
|
os.chdir(ws.base_dir)
|
|
73
99
|
log.warning("Running in workspace: %s", ws.base_dir)
|
|
74
100
|
|
|
@@ -83,5 +109,16 @@ def main():
|
|
|
83
109
|
run_mcp_server(mcp_mode, proxy_to=proxy_to)
|
|
84
110
|
|
|
85
111
|
|
|
112
|
+
def main():
|
|
113
|
+
args = build_parser().parse_args()
|
|
114
|
+
|
|
115
|
+
if args.list_tools or args.tool_help:
|
|
116
|
+
kash_setup(rich_logging=True, level=LogLevel.warning)
|
|
117
|
+
show_tool_info(args.tool_help)
|
|
118
|
+
else:
|
|
119
|
+
kash_setup(rich_logging=False, level=LogLevel.info)
|
|
120
|
+
run_server(args)
|
|
121
|
+
|
|
122
|
+
|
|
86
123
|
if __name__ == "__main__":
|
|
87
124
|
main()
|
kash/mcp/mcp_server_commands.py
CHANGED
|
@@ -3,13 +3,11 @@ from pathlib import Path
|
|
|
3
3
|
|
|
4
4
|
from kash.config.logger import get_logger
|
|
5
5
|
from kash.config.settings import (
|
|
6
|
-
get_system_logs_dir,
|
|
7
6
|
global_settings,
|
|
8
|
-
server_log_file_path,
|
|
9
7
|
)
|
|
10
8
|
from kash.exec import kash_command
|
|
11
9
|
from kash.mcp import mcp_server_routes
|
|
12
|
-
from kash.mcp.
|
|
10
|
+
from kash.mcp.mcp_cli import MCP_CLI_LOG_PATH
|
|
13
11
|
from kash.shell.output.shell_formatting import format_name_and_value
|
|
14
12
|
from kash.shell.output.shell_output import cprint, print_h2
|
|
15
13
|
from kash.shell.utils.native_utils import tail_file
|
|
@@ -59,13 +57,14 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
|
|
|
59
57
|
:param follow: Follow the file as it grows.
|
|
60
58
|
:param all: Show all logs, not just the server logs, including Claude Desktop logs if found.
|
|
61
59
|
"""
|
|
60
|
+
settings = global_settings()
|
|
62
61
|
if all:
|
|
63
|
-
global_log_base =
|
|
62
|
+
global_log_base = settings.system_logs_dir
|
|
64
63
|
claude_log_base = Path("~/Library/Logs/Claude").expanduser()
|
|
65
64
|
log_paths = []
|
|
66
65
|
did_log = False
|
|
67
66
|
while len(log_paths) == 0:
|
|
68
|
-
log_paths =
|
|
67
|
+
log_paths = [settings.local_server_log_path, MCP_CLI_LOG_PATH]
|
|
69
68
|
claude_logs = list(claude_log_base.glob("mcp*.log"))
|
|
70
69
|
if claude_logs:
|
|
71
70
|
log.message("Found Claude Desktop logs, will also tail them: %s", claude_logs)
|
|
@@ -82,7 +81,7 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
|
|
|
82
81
|
did_log = True
|
|
83
82
|
time.sleep(1)
|
|
84
83
|
else:
|
|
85
|
-
server_log_path =
|
|
84
|
+
server_log_path = settings.local_server_log_path # MCP logs shared with local server logs.
|
|
86
85
|
if not server_log_path.exists():
|
|
87
86
|
raise InvalidState(
|
|
88
87
|
f"MCP server log not found (forgot to run `start_mcp_server`?): {server_log_path}"
|
kash/mcp/mcp_server_routes.py
CHANGED
|
@@ -8,16 +8,16 @@ from funlog import log_calls
|
|
|
8
8
|
from mcp.server.lowlevel import Server
|
|
9
9
|
from mcp.types import Prompt, Resource, TextContent, Tool
|
|
10
10
|
from prettyfmt import fmt_path
|
|
11
|
+
from strif import AtomicVar
|
|
11
12
|
|
|
12
13
|
from kash.config.capture_output import CapturedOutput, captured_output
|
|
13
14
|
from kash.config.logger import get_logger
|
|
14
|
-
from kash.config.settings import
|
|
15
|
+
from kash.config.settings import global_settings
|
|
15
16
|
from kash.exec.action_exec import prepare_action_input, run_action_with_caching
|
|
16
17
|
from kash.exec.action_registry import get_all_actions_defaults, look_up_action_class
|
|
17
18
|
from kash.model.actions_model import Action, ActionResult, ExecContext
|
|
18
19
|
from kash.model.params_model import TypedParamValues
|
|
19
20
|
from kash.model.paths_model import StorePath
|
|
20
|
-
from kash.utils.common.atomic_var import AtomicVar
|
|
21
21
|
from kash.workspaces.workspaces import current_ws, get_ws
|
|
22
22
|
|
|
23
23
|
log = get_logger(__name__)
|
|
@@ -27,6 +27,13 @@ log = get_logger(__name__)
|
|
|
27
27
|
_mcp_published_actions: AtomicVar[list[str]] = AtomicVar([])
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def get_published_mcp_tools() -> list[str]:
|
|
31
|
+
"""
|
|
32
|
+
Get the list of currently published MCP tools.
|
|
33
|
+
"""
|
|
34
|
+
return _mcp_published_actions.copy()
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
def publish_mcp_tools(action_names: list[str] | None = None) -> None:
|
|
31
38
|
"""
|
|
32
39
|
Add actions to the list of published MCP tools.
|
|
@@ -40,12 +47,8 @@ def publish_mcp_tools(action_names: list[str] | None = None) -> None:
|
|
|
40
47
|
with _mcp_published_actions.updates() as published_actions:
|
|
41
48
|
new_actions = set(action_names).difference(published_actions)
|
|
42
49
|
published_actions.extend(new_actions)
|
|
43
|
-
log.message(
|
|
44
|
-
|
|
45
|
-
len(new_actions),
|
|
46
|
-
len(published_actions),
|
|
47
|
-
new_actions,
|
|
48
|
-
)
|
|
50
|
+
log.message("Adding %s MCP tools (total now %s)", len(new_actions), len(published_actions))
|
|
51
|
+
log.info("Current MCP tools: %s", ", ".join(published_actions))
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
def unpublish_mcp_tools(action_names: list[str] | None) -> None:
|
|
@@ -210,7 +213,7 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
210
213
|
# XXX For now, unless the user has overridden the MCP workspace, we use the
|
|
211
214
|
# current workspace, which could be changed by the user by changing working
|
|
212
215
|
# directories. Maybe confusing?
|
|
213
|
-
explicit_mcp_ws =
|
|
216
|
+
explicit_mcp_ws = global_settings().mcp_ws_dir
|
|
214
217
|
ws = get_ws(explicit_mcp_ws) if explicit_mcp_ws else current_ws()
|
|
215
218
|
|
|
216
219
|
with ws:
|
|
@@ -228,8 +231,8 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
228
231
|
context = ExecContext(
|
|
229
232
|
action=action,
|
|
230
233
|
workspace_dir=ws.base_dir,
|
|
231
|
-
# Enabling rerun always for now, seems good for tools.
|
|
232
|
-
|
|
234
|
+
rerun=True, # Enabling rerun always for now, seems good for tools.
|
|
235
|
+
refetch=False, # Using the file caches.
|
|
233
236
|
# Keeping all transient files for now, but maybe make transient?
|
|
234
237
|
override_state=None,
|
|
235
238
|
)
|
kash/mcp/mcp_server_sse.py
CHANGED
|
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import threading
|
|
5
5
|
from functools import cached_property
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
from mcp.server.sse import SseServerTransport
|
|
10
|
+
from prettyfmt import fmt_path
|
|
9
11
|
from sse_starlette.sse import AppStatus
|
|
10
12
|
from starlette.applications import Starlette
|
|
11
13
|
from starlette.routing import Mount, Route
|
|
@@ -16,14 +18,14 @@ if TYPE_CHECKING:
|
|
|
16
18
|
|
|
17
19
|
from kash.config.logger import get_logger
|
|
18
20
|
from kash.config.server_config import create_server_config
|
|
19
|
-
from kash.config.settings import global_settings
|
|
21
|
+
from kash.config.settings import global_settings
|
|
22
|
+
from kash.local_server.port_tools import find_available_local_port
|
|
20
23
|
from kash.mcp import mcp_server_routes
|
|
21
24
|
from kash.utils.errors import InvalidState
|
|
22
25
|
|
|
23
26
|
log = get_logger(__name__)
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
MCP_SERVER_NAME = f"{MCP_LOG_PREFIX}_server_sse"
|
|
28
|
+
MCP_SERVER_NAME = "mcp_server_sse"
|
|
27
29
|
MCP_SERVER_HOST = "127.0.0.1"
|
|
28
30
|
"""The local hostname to run the MCP SSE server on."""
|
|
29
31
|
|
|
@@ -47,64 +49,84 @@ def create_mcp_app() -> Starlette:
|
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
class MCPServerSSE:
|
|
50
|
-
def __init__(self):
|
|
52
|
+
def __init__(self, server_name: str, host: str, log_path: Path):
|
|
53
|
+
self.server_name = server_name
|
|
54
|
+
self.host = host
|
|
55
|
+
self.log_path = log_path
|
|
51
56
|
self.server_lock = threading.RLock()
|
|
52
57
|
self.server_instance: uvicorn.Server | None = None
|
|
53
58
|
self.did_exit = threading.Event()
|
|
59
|
+
self.port: int
|
|
54
60
|
|
|
55
61
|
@cached_property
|
|
56
62
|
def app(self) -> Starlette:
|
|
57
63
|
return create_mcp_app()
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
@property
|
|
66
|
+
def host_port(self) -> str | None:
|
|
67
|
+
if self.server_instance:
|
|
68
|
+
return f"{self.server_instance.config.host}:{self.server_instance.config.port}"
|
|
69
|
+
else:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def _setup_server(self):
|
|
60
73
|
import uvicorn
|
|
61
74
|
|
|
62
75
|
# Reset AppStatus.should_exit_event to None to ensure it's created
|
|
63
|
-
# in the correct event loop when needed
|
|
76
|
+
# in the correct event loop when needed.
|
|
64
77
|
AppStatus.should_exit_event = None
|
|
65
78
|
|
|
66
79
|
port = global_settings().mcp_server_port
|
|
67
|
-
self.log_path = server_log_file_path(MCP_SERVER_NAME, port)
|
|
68
|
-
config = create_server_config(
|
|
69
|
-
self.app, MCP_SERVER_HOST, port, MCP_SERVER_NAME, self.log_path
|
|
70
|
-
)
|
|
71
|
-
with self.server_lock:
|
|
72
|
-
server = uvicorn.Server(config)
|
|
73
|
-
self.server_instance = server
|
|
74
|
-
|
|
75
|
-
async def serve():
|
|
76
|
-
try:
|
|
77
|
-
log.message(
|
|
78
|
-
"Starting MCP server on %s:%s",
|
|
79
|
-
MCP_SERVER_HOST,
|
|
80
|
-
port,
|
|
81
|
-
)
|
|
82
|
-
await server.serve()
|
|
83
|
-
finally:
|
|
84
|
-
self.did_exit.set()
|
|
85
80
|
|
|
81
|
+
# Check if the port is available.
|
|
86
82
|
try:
|
|
87
|
-
|
|
83
|
+
find_available_local_port(self.host, [port])
|
|
84
|
+
except RuntimeError:
|
|
85
|
+
log.warning(
|
|
86
|
+
f"MCP Server port {port} ({self.server_name}) is in use. Will not start another server."
|
|
87
|
+
)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
self.port = port
|
|
91
|
+
|
|
92
|
+
config = create_server_config(self.app, self.host, port, self.server_name, self.log_path)
|
|
93
|
+
|
|
94
|
+
server = uvicorn.Server(config)
|
|
95
|
+
self.server_instance = server
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def _run_server_thread(self):
|
|
99
|
+
assert self.server_instance
|
|
100
|
+
try:
|
|
101
|
+
asyncio.run(self.server_instance.serve())
|
|
88
102
|
except Exception as e:
|
|
89
103
|
log.error("MCP Server failed with error: %s", e)
|
|
90
104
|
finally:
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
self.server_instance = None
|
|
106
|
+
self.did_exit.set()
|
|
93
107
|
|
|
94
108
|
def start_server(self):
|
|
95
109
|
with self.server_lock:
|
|
96
110
|
if self.server_instance:
|
|
97
111
|
log.warning(
|
|
98
|
-
"MCP Server already running on %s
|
|
99
|
-
self.
|
|
100
|
-
self.server_instance.config.port,
|
|
112
|
+
"MCP Server already running on: %s",
|
|
113
|
+
self.host_port,
|
|
101
114
|
)
|
|
102
115
|
return
|
|
103
116
|
|
|
104
117
|
self.did_exit.clear()
|
|
105
|
-
|
|
118
|
+
if not self._setup_server():
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
server_thread = threading.Thread(target=self._run_server_thread, daemon=True)
|
|
106
122
|
server_thread.start()
|
|
107
123
|
log.info("Created new MCP server thread: %s", server_thread)
|
|
124
|
+
log.message(
|
|
125
|
+
"Started server %s on %s with logs to %s",
|
|
126
|
+
self.server_name,
|
|
127
|
+
self.host_port,
|
|
128
|
+
fmt_path(self.log_path),
|
|
129
|
+
)
|
|
108
130
|
|
|
109
131
|
def stop_server(self):
|
|
110
132
|
with self.server_lock:
|
|
@@ -113,6 +135,7 @@ class MCPServerSSE:
|
|
|
113
135
|
return
|
|
114
136
|
self.server_instance.should_exit = True
|
|
115
137
|
|
|
138
|
+
# Wait a few seconds for the server to shut down.
|
|
116
139
|
timeout = 5.0
|
|
117
140
|
if not self.did_exit.wait(timeout=timeout):
|
|
118
141
|
log.warning(
|
|
@@ -123,7 +146,7 @@ class MCPServerSSE:
|
|
|
123
146
|
raise InvalidState(f"MCP Server did not shut down within {timeout} seconds")
|
|
124
147
|
|
|
125
148
|
self.server_instance = None
|
|
126
|
-
log.warning("
|
|
149
|
+
log.warning("Stopped server %s", self.server_name)
|
|
127
150
|
|
|
128
151
|
def restart_server(self):
|
|
129
152
|
self.stop_server()
|
|
@@ -131,7 +154,9 @@ class MCPServerSSE:
|
|
|
131
154
|
|
|
132
155
|
|
|
133
156
|
# Singleton instance
|
|
134
|
-
_mcp_sse_server = MCPServerSSE(
|
|
157
|
+
_mcp_sse_server = MCPServerSSE(
|
|
158
|
+
MCP_SERVER_NAME, MCP_SERVER_HOST, global_settings().local_server_log_path
|
|
159
|
+
)
|
|
135
160
|
|
|
136
161
|
|
|
137
162
|
def start_mcp_server_sse():
|
|
@@ -150,7 +175,9 @@ def restart_mcp_server_sse():
|
|
|
150
175
|
|
|
151
176
|
|
|
152
177
|
def run_mcp_server_sse():
|
|
153
|
-
"""
|
|
178
|
+
"""
|
|
179
|
+
Run server, blocking until shutdown.
|
|
180
|
+
"""
|
|
154
181
|
try:
|
|
155
182
|
start_mcp_server_sse()
|
|
156
183
|
_mcp_sse_server.did_exit.wait()
|
kash/mcp/mcp_server_stdio.py
CHANGED
|
@@ -5,18 +5,10 @@ import sys
|
|
|
5
5
|
from anyio import ClosedResourceError
|
|
6
6
|
from mcp.server.stdio import stdio_server
|
|
7
7
|
|
|
8
|
-
from kash.config.settings import server_log_file_path
|
|
9
8
|
from kash.mcp import mcp_server_routes
|
|
10
|
-
from kash.mcp.mcp_server_sse import MCP_LOG_PREFIX
|
|
11
9
|
|
|
12
10
|
log = logging.getLogger(__name__)
|
|
13
11
|
|
|
14
|
-
MCP_SERVER_NAME = f"{MCP_LOG_PREFIX}_server_stdio"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def get_log_path():
|
|
18
|
-
return server_log_file_path(MCP_SERVER_NAME, "stdio")
|
|
19
|
-
|
|
20
12
|
|
|
21
13
|
def run_mcp_server_stdio():
|
|
22
14
|
"""
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
2
3
|
from os.path import getsize
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
6
|
+
from prettyfmt import fmt_path, fmt_size_human
|
|
5
7
|
from strif import atomic_output_file
|
|
6
8
|
|
|
7
9
|
log = logging.getLogger(__name__)
|
|
8
10
|
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AudioFileStats:
|
|
14
|
+
duration: float
|
|
15
|
+
size: int
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"duration {self.duration:.2f}s, size {fmt_size_human(self.size)}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def downsample_to_16khz(
|
|
22
|
+
audio_file_path: Path, downsampled_out_path: Path
|
|
23
|
+
) -> tuple[AudioFileStats, AudioFileStats]:
|
|
11
24
|
from pydub import AudioSegment
|
|
12
25
|
|
|
13
26
|
audio = AudioSegment.from_mp3(audio_file_path)
|
|
@@ -16,11 +29,72 @@ def downsample_to_16khz(audio_file_path: Path, downsampled_out_path: Path) -> No
|
|
|
16
29
|
with atomic_output_file(downsampled_out_path) as temp_target:
|
|
17
30
|
audio.export(temp_target, format="mp3")
|
|
18
31
|
|
|
32
|
+
before = AudioFileStats(
|
|
33
|
+
duration=len(audio) / 1000,
|
|
34
|
+
size=getsize(audio_file_path),
|
|
35
|
+
)
|
|
36
|
+
after = AudioFileStats(
|
|
37
|
+
duration=len(audio) / 1000,
|
|
38
|
+
size=getsize(downsampled_out_path),
|
|
39
|
+
)
|
|
19
40
|
log.info(
|
|
20
|
-
"Downsampled %s -> %s:
|
|
21
|
-
audio_file_path,
|
|
22
|
-
downsampled_out_path,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
"Downsampled %s -> %s: %s to 16kHz %s (%sX reduction)",
|
|
42
|
+
fmt_path(audio_file_path),
|
|
43
|
+
fmt_path(downsampled_out_path),
|
|
44
|
+
before,
|
|
45
|
+
after,
|
|
46
|
+
before.size / after.size,
|
|
26
47
|
)
|
|
48
|
+
|
|
49
|
+
return before, after
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# TODO: Test and integrate with JSON caching of transcription results.
|
|
53
|
+
def slice_audio_segments(
|
|
54
|
+
audio_file_path: Path, segments: list[tuple[float, float]], output_path: Path
|
|
55
|
+
) -> tuple[AudioFileStats, AudioFileStats]:
|
|
56
|
+
"""
|
|
57
|
+
Takes a list of time segments in seconds and creates a new audio file
|
|
58
|
+
containing only those segments concatenated together.
|
|
59
|
+
"""
|
|
60
|
+
from pydub import AudioSegment
|
|
61
|
+
|
|
62
|
+
# Load the audio file.
|
|
63
|
+
audio = AudioSegment.from_file(audio_file_path)
|
|
64
|
+
audio_duration = len(audio) / 1000
|
|
65
|
+
|
|
66
|
+
# Extract and concatenate each segment.
|
|
67
|
+
result: AudioSegment = AudioSegment.empty()
|
|
68
|
+
slices_duration = 0
|
|
69
|
+
for start_sec, end_sec in segments:
|
|
70
|
+
# Convert seconds to milliseconds for pydub.
|
|
71
|
+
start_ms = int(start_sec * 1000)
|
|
72
|
+
end_ms = int(end_sec * 1000)
|
|
73
|
+
|
|
74
|
+
# Extract the segment and add to result.
|
|
75
|
+
segment_audio = audio[start_ms:end_ms]
|
|
76
|
+
result += segment_audio
|
|
77
|
+
slices_duration += end_sec - start_sec
|
|
78
|
+
|
|
79
|
+
# Export the concatenated audio.
|
|
80
|
+
with atomic_output_file(output_path) as temp_target:
|
|
81
|
+
result.export(temp_target, format="mp3")
|
|
82
|
+
|
|
83
|
+
before = AudioFileStats(
|
|
84
|
+
duration=audio_duration,
|
|
85
|
+
size=getsize(audio_file_path),
|
|
86
|
+
)
|
|
87
|
+
after = AudioFileStats(
|
|
88
|
+
duration=slices_duration,
|
|
89
|
+
size=getsize(output_path),
|
|
90
|
+
)
|
|
91
|
+
log.info(
|
|
92
|
+
"Sliced audio: %s -> %s: extracted %d segments, %s to %s",
|
|
93
|
+
fmt_path(audio_file_path),
|
|
94
|
+
fmt_path(output_path),
|
|
95
|
+
len(segments),
|
|
96
|
+
before,
|
|
97
|
+
after,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return before, after
|