diff --git a/docs/docs/assets/social/acp.png b/docs/docs/assets/social/acp.png index 261623c26..0c8244855 100644 Binary files a/docs/docs/assets/social/acp.png and b/docs/docs/assets/social/acp.png differ diff --git a/docs/docs/assets/social/acp/customizing_agents.png b/docs/docs/assets/social/acp/customizing_agents.png index d641ce357..ae61144f1 100644 Binary files a/docs/docs/assets/social/acp/customizing_agents.png and b/docs/docs/assets/social/acp/customizing_agents.png differ diff --git a/docs/docs/assets/social/agents.png b/docs/docs/assets/social/agents.png index dfa16e540..497ecf77e 100644 Binary files a/docs/docs/assets/social/agents.png and b/docs/docs/assets/social/agents.png differ diff --git a/docs/docs/assets/social/agents/defining.png b/docs/docs/assets/social/agents/defining.png index 2f4deda9e..723a2d97e 100644 Binary files a/docs/docs/assets/social/agents/defining.png and b/docs/docs/assets/social/agents/defining.png differ diff --git a/docs/docs/assets/social/agents/defining/agent_cards.png b/docs/docs/assets/social/agents/defining/agent_cards.png index db61354ad..7060acb0a 100644 Binary files a/docs/docs/assets/social/agents/defining/agent_cards.png and b/docs/docs/assets/social/agents/defining/agent_cards.png differ diff --git a/docs/docs/assets/social/agents/defining/python_api.png b/docs/docs/assets/social/agents/defining/python_api.png index 72875ffb4..b866d05cd 100644 Binary files a/docs/docs/assets/social/agents/defining/python_api.png and b/docs/docs/assets/social/agents/defining/python_api.png differ diff --git a/docs/docs/assets/social/agents/instructions.png b/docs/docs/assets/social/agents/instructions.png index 7398536ad..d8abfb57c 100644 Binary files a/docs/docs/assets/social/agents/instructions.png and b/docs/docs/assets/social/agents/instructions.png differ diff --git a/docs/docs/assets/social/agents/plugins.png b/docs/docs/assets/social/agents/plugins.png index 67beed244..6a0ae8589 100644 Binary files a/docs/docs/assets/social/agents/plugins.png and b/docs/docs/assets/social/agents/plugins.png differ diff --git a/docs/docs/assets/social/agents/prompting.png b/docs/docs/assets/social/agents/prompting.png index 82a6ece14..35cb7dcbc 100644 Binary files a/docs/docs/assets/social/agents/prompting.png and b/docs/docs/assets/social/agents/prompting.png differ diff --git a/docs/docs/assets/social/agents/running.png b/docs/docs/assets/social/agents/running.png index 5e49f1450..11a784f60 100644 Binary files a/docs/docs/assets/social/agents/running.png and b/docs/docs/assets/social/agents/running.png differ diff --git a/docs/docs/assets/social/agents/tool_runner.png b/docs/docs/assets/social/agents/tool_runner.png index 469e888de..cf23f14d0 100644 Binary files a/docs/docs/assets/social/agents/tool_runner.png and b/docs/docs/assets/social/agents/tool_runner.png differ diff --git a/docs/docs/assets/social/agents/workflows.png b/docs/docs/assets/social/agents/workflows.png index 2c73a5350..7abd7afa5 100644 Binary files a/docs/docs/assets/social/agents/workflows.png and b/docs/docs/assets/social/agents/workflows.png differ diff --git a/docs/docs/assets/social/getting_started.png b/docs/docs/assets/social/getting_started.png index 6202d6bc1..7ce3853d3 100644 Binary files a/docs/docs/assets/social/getting_started.png and b/docs/docs/assets/social/getting_started.png differ diff --git a/docs/docs/assets/social/getting_started/installation.png b/docs/docs/assets/social/getting_started/installation.png index b693931e2..fbe81e683 100644 Binary files a/docs/docs/assets/social/getting_started/installation.png and b/docs/docs/assets/social/getting_started/installation.png differ diff --git a/docs/docs/assets/social/guides/batch-processing.png b/docs/docs/assets/social/guides/batch-processing.png index 750b990be..f752cc953 100644 Binary files a/docs/docs/assets/social/guides/batch-processing.png and b/docs/docs/assets/social/guides/batch-processing.png differ diff --git a/docs/docs/assets/social/guides/codex.png b/docs/docs/assets/social/guides/codex.png index 59854c3f6..aec5516f9 100644 Binary files a/docs/docs/assets/social/guides/codex.png and b/docs/docs/assets/social/guides/codex.png differ diff --git a/docs/docs/assets/social/guides/core-concepts.png b/docs/docs/assets/social/guides/core-concepts.png index 8cb2b7cf1..de7d005b7 100644 Binary files a/docs/docs/assets/social/guides/core-concepts.png and b/docs/docs/assets/social/guides/core-concepts.png differ diff --git a/docs/docs/assets/social/guides/hf-dev.png b/docs/docs/assets/social/guides/hf-dev.png index c969fe63e..2d5231abc 100644 Binary files a/docs/docs/assets/social/guides/hf-dev.png and b/docs/docs/assets/social/guides/hf-dev.png differ diff --git a/docs/docs/assets/social/guides/privacy_filter.png b/docs/docs/assets/social/guides/privacy_filter.png index 4a46923a8..a57bd9df9 100644 Binary files a/docs/docs/assets/social/guides/privacy_filter.png and b/docs/docs/assets/social/guides/privacy_filter.png differ diff --git a/docs/docs/assets/social/guides/skills.png b/docs/docs/assets/social/guides/skills.png index 057da3570..a5b5fada0 100644 Binary files a/docs/docs/assets/social/guides/skills.png and b/docs/docs/assets/social/guides/skills.png differ diff --git a/docs/docs/assets/social/guides/structured-outputs.png b/docs/docs/assets/social/guides/structured-outputs.png index f7f0feb8c..996b19ff3 100644 Binary files a/docs/docs/assets/social/guides/structured-outputs.png and b/docs/docs/assets/social/guides/structured-outputs.png differ diff --git a/docs/docs/assets/social/guides/tui.png b/docs/docs/assets/social/guides/tui.png index d2b76f437..c85f6a610 100644 Binary files a/docs/docs/assets/social/guides/tui.png and b/docs/docs/assets/social/guides/tui.png differ diff --git a/docs/docs/assets/social/index.png b/docs/docs/assets/social/index.png index ab9e5eb56..1a0ebc617 100644 Binary files a/docs/docs/assets/social/index.png and b/docs/docs/assets/social/index.png differ diff --git a/docs/docs/assets/social/mcp.png b/docs/docs/assets/social/mcp.png index 6d4a84f73..db54c2a20 100644 Binary files a/docs/docs/assets/social/mcp.png and b/docs/docs/assets/social/mcp.png differ diff --git a/docs/docs/assets/social/mcp/elicitations.png b/docs/docs/assets/social/mcp/elicitations.png index e11a9c60e..dda059c56 100644 Binary files a/docs/docs/assets/social/mcp/elicitations.png and b/docs/docs/assets/social/mcp/elicitations.png differ diff --git a/docs/docs/assets/social/mcp/mcp-oauth.png b/docs/docs/assets/social/mcp/mcp-oauth.png index 013369b9c..b893d7bf0 100644 Binary files a/docs/docs/assets/social/mcp/mcp-oauth.png and b/docs/docs/assets/social/mcp/mcp-oauth.png differ diff --git a/docs/docs/assets/social/mcp/mcp-server.png b/docs/docs/assets/social/mcp/mcp-server.png index 9c344bd96..de728c213 100644 Binary files a/docs/docs/assets/social/mcp/mcp-server.png and b/docs/docs/assets/social/mcp/mcp-server.png differ diff --git a/docs/docs/assets/social/mcp/mcp-ui.png b/docs/docs/assets/social/mcp/mcp-ui.png index 501b38db3..09cfb39b8 100644 Binary files a/docs/docs/assets/social/mcp/mcp-ui.png and b/docs/docs/assets/social/mcp/mcp-ui.png differ diff --git a/docs/docs/assets/social/mcp/mcp_display.png b/docs/docs/assets/social/mcp/mcp_display.png index 1e1e9248c..b3f1f55f6 100644 Binary files a/docs/docs/assets/social/mcp/mcp_display.png and b/docs/docs/assets/social/mcp/mcp_display.png differ diff --git a/docs/docs/assets/social/mcp/openai-apps-sdk.png b/docs/docs/assets/social/mcp/openai-apps-sdk.png index 91e7c1fe7..c99c1ed5a 100644 Binary files a/docs/docs/assets/social/mcp/openai-apps-sdk.png and b/docs/docs/assets/social/mcp/openai-apps-sdk.png differ diff --git a/docs/docs/assets/social/mcp/resources.png b/docs/docs/assets/social/mcp/resources.png index 168631730..8026139c9 100644 Binary files a/docs/docs/assets/social/mcp/resources.png and b/docs/docs/assets/social/mcp/resources.png differ diff --git a/docs/docs/assets/social/mcp/skills-over-mcp.png b/docs/docs/assets/social/mcp/skills-over-mcp.png new file mode 100644 index 000000000..d94288331 Binary files /dev/null and b/docs/docs/assets/social/mcp/skills-over-mcp.png differ diff --git a/docs/docs/assets/social/mcp/state_transfer.png b/docs/docs/assets/social/mcp/state_transfer.png index bffb4a8aa..397c62f26 100644 Binary files a/docs/docs/assets/social/mcp/state_transfer.png and b/docs/docs/assets/social/mcp/state_transfer.png differ diff --git a/docs/docs/assets/social/mcp/types.png b/docs/docs/assets/social/mcp/types.png index f686bd821..6b05be311 100644 Binary files a/docs/docs/assets/social/mcp/types.png and b/docs/docs/assets/social/mcp/types.png differ diff --git a/docs/docs/assets/social/models.png b/docs/docs/assets/social/models.png index 87764bc26..b22447ee6 100644 Binary files a/docs/docs/assets/social/models.png and b/docs/docs/assets/social/models.png differ diff --git a/docs/docs/assets/social/models/internal_models.png b/docs/docs/assets/social/models/internal_models.png index 678434047..da7a2a3c1 100644 Binary files a/docs/docs/assets/social/models/internal_models.png and b/docs/docs/assets/social/models/internal_models.png differ diff --git a/docs/docs/assets/social/models/llm_providers.png b/docs/docs/assets/social/models/llm_providers.png index c82ee1d1e..50519703d 100644 Binary files a/docs/docs/assets/social/models/llm_providers.png and b/docs/docs/assets/social/models/llm_providers.png differ diff --git a/docs/docs/assets/social/models/model_overlays.png b/docs/docs/assets/social/models/model_overlays.png index 3fdaecb59..b40404c4b 100644 Binary files a/docs/docs/assets/social/models/model_overlays.png and b/docs/docs/assets/social/models/model_overlays.png differ diff --git a/docs/docs/assets/social/models/models_reference.png b/docs/docs/assets/social/models/models_reference.png index 10fc1cba0..bbec6764e 100644 Binary files a/docs/docs/assets/social/models/models_reference.png and b/docs/docs/assets/social/models/models_reference.png differ diff --git a/docs/docs/assets/social/models/providers/additional.png b/docs/docs/assets/social/models/providers/additional.png index a9eff5f54..3c4e5f2bb 100644 Binary files a/docs/docs/assets/social/models/providers/additional.png and b/docs/docs/assets/social/models/providers/additional.png differ diff --git a/docs/docs/assets/social/models/providers/aliyun.png b/docs/docs/assets/social/models/providers/aliyun.png index cb7aff597..82b440be7 100644 Binary files a/docs/docs/assets/social/models/providers/aliyun.png and b/docs/docs/assets/social/models/providers/aliyun.png differ diff --git a/docs/docs/assets/social/models/providers/anthropic.png b/docs/docs/assets/social/models/providers/anthropic.png index 695133780..cde05c9bd 100644 Binary files a/docs/docs/assets/social/models/providers/anthropic.png and b/docs/docs/assets/social/models/providers/anthropic.png differ diff --git a/docs/docs/assets/social/models/providers/azure.png b/docs/docs/assets/social/models/providers/azure.png index 0ef8c1713..93513d6af 100644 Binary files a/docs/docs/assets/social/models/providers/azure.png and b/docs/docs/assets/social/models/providers/azure.png differ diff --git a/docs/docs/assets/social/models/providers/bedrock.png b/docs/docs/assets/social/models/providers/bedrock.png index 36b55422f..e9381e015 100644 Binary files a/docs/docs/assets/social/models/providers/bedrock.png and b/docs/docs/assets/social/models/providers/bedrock.png differ diff --git a/docs/docs/assets/social/models/providers/deepseek.png b/docs/docs/assets/social/models/providers/deepseek.png index 5034e084b..d4f9606c4 100644 Binary files a/docs/docs/assets/social/models/providers/deepseek.png and b/docs/docs/assets/social/models/providers/deepseek.png differ diff --git a/docs/docs/assets/social/models/providers/generic.png b/docs/docs/assets/social/models/providers/generic.png index d2862e216..65610ef9d 100644 Binary files a/docs/docs/assets/social/models/providers/generic.png and b/docs/docs/assets/social/models/providers/generic.png differ diff --git a/docs/docs/assets/social/models/providers/google.png b/docs/docs/assets/social/models/providers/google.png index 5815fa129..87e864289 100644 Binary files a/docs/docs/assets/social/models/providers/google.png and b/docs/docs/assets/social/models/providers/google.png differ diff --git a/docs/docs/assets/social/models/providers/groq.png b/docs/docs/assets/social/models/providers/groq.png index eba3c9822..7f3b06cae 100644 Binary files a/docs/docs/assets/social/models/providers/groq.png and b/docs/docs/assets/social/models/providers/groq.png differ diff --git a/docs/docs/assets/social/models/providers/huggingface.png b/docs/docs/assets/social/models/providers/huggingface.png index 142719a81..0b87ccf66 100644 Binary files a/docs/docs/assets/social/models/providers/huggingface.png and b/docs/docs/assets/social/models/providers/huggingface.png differ diff --git a/docs/docs/assets/social/models/providers/openai.png b/docs/docs/assets/social/models/providers/openai.png index c1da3d81a..784685d79 100644 Binary files a/docs/docs/assets/social/models/providers/openai.png and b/docs/docs/assets/social/models/providers/openai.png differ diff --git a/docs/docs/assets/social/models/providers/openrouter.png b/docs/docs/assets/social/models/providers/openrouter.png index 6c52b2b8a..f451de1f8 100644 Binary files a/docs/docs/assets/social/models/providers/openrouter.png and b/docs/docs/assets/social/models/providers/openrouter.png differ diff --git a/docs/docs/assets/social/models/providers/xai.png b/docs/docs/assets/social/models/providers/xai.png index 330ffe6c2..df45a7ecd 100644 Binary files a/docs/docs/assets/social/models/providers/xai.png and b/docs/docs/assets/social/models/providers/xai.png differ diff --git a/docs/docs/assets/social/ref/about.png b/docs/docs/assets/social/ref/about.png index dbed79917..c523b8320 100644 Binary files a/docs/docs/assets/social/ref/about.png and b/docs/docs/assets/social/ref/about.png differ diff --git a/docs/docs/assets/social/ref/azure-config.png b/docs/docs/assets/social/ref/azure-config.png index 0610477e7..d995bf4f8 100644 Binary files a/docs/docs/assets/social/ref/azure-config.png and b/docs/docs/assets/social/ref/azure-config.png differ diff --git a/docs/docs/assets/social/ref/batch.png b/docs/docs/assets/social/ref/batch.png index 591b40b9e..7ebb408ab 100644 Binary files a/docs/docs/assets/social/ref/batch.png and b/docs/docs/assets/social/ref/batch.png differ diff --git a/docs/docs/assets/social/ref/class_reference.png b/docs/docs/assets/social/ref/class_reference.png index 2cd52dc7d..d6725c512 100644 Binary files a/docs/docs/assets/social/ref/class_reference.png and b/docs/docs/assets/social/ref/class_reference.png differ diff --git a/docs/docs/assets/social/ref/cmd_switches.png b/docs/docs/assets/social/ref/cmd_switches.png index cd998454c..bca049a2f 100644 Binary files a/docs/docs/assets/social/ref/cmd_switches.png and b/docs/docs/assets/social/ref/cmd_switches.png differ diff --git a/docs/docs/assets/social/ref/config_file.png b/docs/docs/assets/social/ref/config_file.png index adaf9894c..078e29817 100644 Binary files a/docs/docs/assets/social/ref/config_file.png and b/docs/docs/assets/social/ref/config_file.png differ diff --git a/docs/docs/assets/social/ref/docs_automation.png b/docs/docs/assets/social/ref/docs_automation.png index f4393f27e..459db1ed2 100644 Binary files a/docs/docs/assets/social/ref/docs_automation.png and b/docs/docs/assets/social/ref/docs_automation.png differ diff --git a/docs/docs/assets/social/ref/export_command.png b/docs/docs/assets/social/ref/export_command.png index 5c1d8b2c4..5dfc4c358 100644 Binary files a/docs/docs/assets/social/ref/export_command.png and b/docs/docs/assets/social/ref/export_command.png differ diff --git a/docs/docs/assets/social/ref/fast_agent_ai_articles.png b/docs/docs/assets/social/ref/fast_agent_ai_articles.png index 946267201..bf7eec7c4 100644 Binary files a/docs/docs/assets/social/ref/fast_agent_ai_articles.png and b/docs/docs/assets/social/ref/fast_agent_ai_articles.png differ diff --git a/docs/docs/assets/social/ref/generated_docs.png b/docs/docs/assets/social/ref/generated_docs.png index 5842d9958..366ceb6be 100644 Binary files a/docs/docs/assets/social/ref/generated_docs.png and b/docs/docs/assets/social/ref/generated_docs.png differ diff --git a/docs/docs/assets/social/ref/go_command.png b/docs/docs/assets/social/ref/go_command.png index 436411309..7e8416c49 100644 Binary files a/docs/docs/assets/social/ref/go_command.png and b/docs/docs/assets/social/ref/go_command.png differ diff --git a/docs/docs/assets/social/ref/open_telemetry.png b/docs/docs/assets/social/ref/open_telemetry.png index 2a9bbe988..635fe9dc6 100644 Binary files a/docs/docs/assets/social/ref/open_telemetry.png and b/docs/docs/assets/social/ref/open_telemetry.png differ diff --git a/docs/docs/assets/social/welcome.png b/docs/docs/assets/social/welcome.png index d0fc9804a..2b7636655 100644 Binary files a/docs/docs/assets/social/welcome.png and b/docs/docs/assets/social/welcome.png differ diff --git a/docs/docs/assets/tui/skills-over-mcp.cast b/docs/docs/assets/tui/skills-over-mcp.cast new file mode 100644 index 000000000..d92fd20ed --- /dev/null +++ b/docs/docs/assets/tui/skills-over-mcp.cast @@ -0,0 +1,361 @@ +{"version": 2, "width": 96, "height": 22, "timestamp": 1780413136, "idle_time_limit": 1.3, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}, "title": "fast-agent Skills over MCP"} +[0.033461, "o", "\u001b[?1049h\u001b[22;0;0t\u001b[?1h\u001b=\u001b[H\u001b[2J\u001b[?12l\u001b[?25h\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1005l\u001b[?2004h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[?1006l\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[1;1H\u001b[1;22r\u001b[c\u001b[>c\u001b[>q\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b[1;11H\u001b[?25l\u001b[Hbash-5.2$ \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[1;11H"] +[0.034167, "o", "\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[?1006l\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[1;1H\u001b[1;22r\u001b[1;11H\u001b[?25l\u001b[Hbash-5.2$ \u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[1;11H"] +[1.038085, "o", "u"] +[1.079837, "o", "v"] +[1.128556, "o", " "] +[1.17729, "o", "r"] +[1.227517, "o", "u"] +[1.276121, "o", "n"] +[1.3245, "o", " "] +[1.384189, "o", "f"] +[1.430504, "o", "a"] +[1.476965, "o", "s"] +[1.525621, "o", "t"] +[1.575254, "o", "-"] +[1.622897, "o", "a"] +[1.668557, "o", "g"] +[1.710722, "o", "e"] +[1.755885, "o", "n"] +[1.800601, "o", "t"] +[1.84355, "o", " "] +[1.887505, "o", "-"] +[1.939585, "o", "x"] +[1.991147, "o", " "] +[2.04244, "o", "-"] +[2.094275, "o", "-"] +[2.140419, "o", "m"] +[2.188227, "o", "o"] +[2.235097, "o", "d"] +[2.288763, "o", "e"] +[2.333382, "o", "l"] +[2.38061, "o", " "] +[2.428102, "o", "p"] +[2.47866, "o", "a"] +[2.523185, "o", "s"] +[2.565608, "o", "s"] +[2.609908, "o", "t"] +[2.657147, "o", "h"] +[2.699724, "o", "r"] +[2.741951, "o", "o"] +[2.784739, "o", "u"] +[2.837079, "o", "g"] +[2.885046, "o", "h"] +[2.926778, "o", "\r\n"] +[5.035242, "o", "\u001b[?7727h\u001b(B\u001b[m\u001b[?12l\u001b[?25h\u001b[?1006l\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[1;1H\u001b[1;22r\u001b[2;1H\u001b[?25l\u001b[Hbash-5.2$ uv run fast-agent -x --model passthrough\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[?12l\u001b[?25h\u001b[2d"] +[5.178846, "o", "\u001b[?25l"] +[5.360013, "o", "\u001b[?12l\u001b[?25h"] +[5.376049, "o", "\u001b[2mUse \u001b[32m'/'\u001b[39m for commands, \u001b[32m'!'\u001b[39m for shell. \u001b[32m'#'\u001b[39m to query, \u001b[32m'@'\u001b[39m to switch agents\r\nCTRL+T multiline, CTRL+Y copy last message, CTRL+E external editor.\r\nCTRL+Space or Tab for path completion. Use \u001b[35m/\u001b[95mattach\u001b[39m, `^file:`, or `^url:` for attachments. F10 to\u001b[5;1Hclear.\r\n\u001b(B\u001b[m"] +[5.376989, "o", "\u001b[2mfast-agent environment\u001b(B\u001b[m \u001b[34m/tmp/tmp.UdUUQNuO1o\u001b[39m\u001b[2m \u001b[1m(\u001b[36m1\u001b(B\u001b[m\u001b[2m agent, \u001b[36m\u001b[1m0\u001b(B\u001b[m\u001b[2m hooks, \u001b[36m\u001b[1m0\u001b(B\u001b[m\u001b[2m extensions, \u001b[36m\u001b[1m0\u001b(B\u001b[m\u001b[2m modelrefs\u001b[1m)\u001b(B\u001b[m\u001b[2m via \r\nFAST_AGENT_HOME\r\n\u001b(B\u001b[m"] +[5.377321, "o", "\u001b[33m\u001b[1mAgents have shell\u001b(B\u001b[m\u001b[2m (switch, bash | cwd: tmp/fast-agent-skills-mcp.VHo0tO)\r\n\u001b(B\u001b[m"] +[5.37974, "o", "\u001b[2mStreaming Enabled - markdown mode\r\n\u001b(B\u001b[m\r\n"] +[5.388753, "o", "\r\n\u001b[J\u001b[A\u001b[K\u001b[93m❯\u001b[C\u001b(B\u001b[m"] +[5.399106, "o", "\u001b[22;1H\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[11;3H\u001b(B\u001b[m"] +[6.445212, "o", "/"] +[6.464173, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[13;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[14;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;238m \u001b[15;1H\u001b[32m\u001b[40m tools \u001b[34m List tools \u001b[38;5;16m\u001b[48;5;238m \u001b[16;1H\u001b[32m\u001b[40m model \u001b[34m Inspect/switch models and update runtime settings (/model reasoning|task_budget|... \u001b[38;5;16m\u001b[48;5;238m \u001b[17;1H\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills ... \u001b[38;5;16m\u001b[48;5;248m \u001b[18;1H\u001b[32m\u001b[40m cards \u001b[34m Manage card packs (/cards, /cards add, /cards remove, /cards update, /cards publ... \u001b[38;5;16m\u001b[48;5;248m \u001b[19;1H\u001b[32m\u001b[40m plugins \u001b[34m Manage command plugins (/plugins, /plugins available, /plugins add, /plugins rem... \u001b[38;5;16m\u001b[48;5;248m \u001b[20;1H\u001b[32m\u001b[40m prompt \u001b[34m Load a Prompt File or use MCP Prompt \u001b[38;5;16m\u001b[48;5;248m \u001b[21;1H\u001b[32m\u001b[40m attach \u001b[34m Stage file path or remote URL attachment token(s) for the next prompt \u001b[38;5;16m\u001b[48;5;248m \u001b[11;4H\u001b(B\u001b[m"] +[6.487835, "o", "m\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[11;5H"] +[6.501893, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[13;1H\u001b[32m\u001b[40m model \u001b[34m Inspect/switch models and update runtime settings (/model reasoning|task_budget|... \u001b[38;5;16m\u001b[48;5;238m \u001b[14;1H\u001b[32m\u001b[40m markdown \u001b[34m Show last assistant message without markdown formatting \u001b[38;5;16m\u001b[48;5;238m \u001b[11;5H\u001b(B\u001b[m"] +[6.533664, "o", "c\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[11;6H"] +[6.55133, "o", "\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[11;6H\u001b(B\u001b[m"] +[6.577291, "o", "p\u001b[12;2H\u001b[K\u001b[11;7H"] +[6.618233, "o", "\u001b[C"] +[6.626818, "o", "\n\u001b[32m\u001b[40m list \u001b[34m List currently attached MCP servers \u001b[38;5;16m\u001b[48;5;238m \u001b[13;8H\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[14;8H\u001b[32m\u001b[40m disconnect \u001b[34m Disconnect an attached MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[15;8H\u001b[32m\u001b[40m reconnect \u001b[34m Reconnect an attached MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[16;8H\u001b[32m\u001b[40m session \u001b[34m Inspect and control MCP data-layer sessions \u001b[38;5;16m\u001b[48;5;238m \u001b[11;8H\u001b(B\u001b[m"] +[6.664061, "o", "c\u001b[12;2H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[11;9H"] +[6.675051, "o", "\n\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[11;9H\u001b(B\u001b[m"] +[6.706131, "o", "o\u001b[12;2H\u001b[K\u001b[11;10H"] +[6.725604, "o", "\n\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[11;10H\u001b(B\u001b[m"] +[6.748956, "o", "n\u001b[12;2H\u001b[K\u001b[11;11H"] +[6.764003, "o", "\n\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[11;11H\u001b(B\u001b[m"] +[6.792229, "o", "n\u001b[12;2H\u001b[K\u001b[11;12H"] +[6.805431, "o", "\n\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[11;12H\u001b(B\u001b[m"] +[6.833095, "o", "e\u001b[12;2H\u001b[K\u001b[11;13H"] +[6.844833, "o", "\n\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[11;13H\u001b(B\u001b[m"] +[6.877657, "o", "c\u001b[12;2H\u001b[K\u001b[11;14H"] +[6.890019, "o", "\n\u001b[32m\u001b[40m connect \u001b[34m Connect a new MCP server \u001b[38;5;16m\u001b[48;5;238m \u001b[11;14H\u001b(B\u001b[m"] +[6.917808, "o", "t\u001b[12;2H\u001b[K\u001b[11;15H"] +[6.96347, "o", "\u001b[C"] +[7.006777, "o", "h"] +[7.054442, "o", "t"] +[7.096632, "o", "t"] +[7.140644, "o", "p"] +[7.190455, "o", "s"] +[7.230516, "o", ":"] +[7.275971, "o", "/"] +[7.318724, "o", "/"] +[7.359876, "o", "h"] +[7.401722, "o", "u"] +[7.447851, "o", "g"] +[7.486816, "o", "g"] +[7.533141, "o", "i"] +[7.57667, "o", "n"] +[7.616733, "o", "g"] +[7.658666, "o", "f"] +[7.699157, "o", "a"] +[7.741857, "o", "c"] +[7.784301, "o", "e"] +[7.825851, "o", "."] +[7.866348, "o", "c"] +[7.908219, "o", "o"] +[7.950713, "o", "/"] +[7.992948, "o", "m"] +[8.035614, "o", "c"] +[8.149749, "o", "p"] +[8.155759, "o", "\u001b[C"] +[8.161768, "o", "-"] +[8.202723, "o", "-"] +[8.213032, "o", "\n\u001b[32m\u001b[40m --name \u001b[34m set attached server name \u001b[38;5;16m\u001b[48;5;238m \u001b[13;45H\u001b[32m\u001b[40m --auth \u001b[34m set bearer token for URL servers \u001b[38;5;16m\u001b[48;5;238m \u001b[14;45H\u001b[32m\u001b[40m --timeout \u001b[34m set startup timeout in seconds \u001b[38;5;16m\u001b[48;5;238m \u001b[15;45H\u001b[32m\u001b[40m --oauth \u001b[34m enable oauth flow \u001b[38;5;16m\u001b[48;5;238m \u001b[16;45H\u001b[32m\u001b[40m --no-oauth \u001b[34m disable oauth flow \u001b[38;5;16m\u001b[48;5;238m \u001b[17;45H\u001b[32m\u001b[40m --reconnect \u001b[34m force reconnect and refresh tools \u001b[38;5;16m\u001b[48;5;238m \u001b[18;45H\u001b[32m\u001b[40m --no-reconnect \u001b[34m disable reconnect-on-disconnect \u001b[38;5;16m\u001b[48;5;238m \u001b[11;45H\u001b(B\u001b[m"] +[8.243286, "o", "n\u001b[12;2H\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[11;46H"] +[8.254534, "o", "\n\u001b[32m\u001b[40m --name \u001b[34m set attached server name \u001b[38;5;16m\u001b[48;5;238m \u001b[13;46H\u001b[32m\u001b[40m --no-oauth \u001b[34m disable oauth flow \u001b[38;5;16m\u001b[48;5;238m \u001b[14;46H\u001b[32m\u001b[40m --no-reconnect \u001b[34m disable reconnect-on-disconnect \u001b[38;5;16m\u001b[48;5;238m \u001b[11;46H\u001b(B\u001b[m"] +[8.28369, "o", "a\u001b[12;2H\u001b[K\n\u001b[K\n\u001b[K\u001b[11;47H"] +[8.291907, "o", "\n\u001b[32m\u001b[40m --name \u001b[34m set attached server name \u001b[38;5;16m\u001b[48;5;238m \u001b[11;47H\u001b(B\u001b[m"] +[8.325524, "o", "m\u001b[12;2H\u001b[K\u001b[11;48H"] +[8.332817, "o", "\n\u001b[32m\u001b[40m --name \u001b[34m set attached server name \u001b[38;5;16m\u001b[48;5;238m \u001b[11;48H\u001b(B\u001b[m"] +[8.373366, "o", "e\u001b[12;2H\u001b[K\u001b[11;49H"] +[8.414482, "o", "\u001b[C"] +[8.457561, "o", "h"] +[8.500457, "o", "f"] +[8.539891, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[8.541656, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mmcp\u001b[39m connect \u001b[94m\u001b[4mhttps://huggingface.co/mcp\u001b(B\u001b[m\u001b[2m --name hf\r\n\u001b(B\u001b[m"] +[8.624398, "o", "\u001b[?25l"] +[8.705515, "o", "\u001b[32m⠋\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[8.786601, "o", "\r\u001b[K\u001b[32m⠙\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[8.86731, "o", "\r\u001b[K\u001b[32m⠹\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[8.948046, "o", "\r\u001b[K\u001b[32m⠸\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.028942, "o", "\r\u001b[K\u001b[32m⠼\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.11042, "o", "\r\u001b[K\u001b[32m⠴\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.191649, "o", "\r\u001b[K\u001b[32m⠦\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.273111, "o", "\r\u001b[K\u001b[32m⠧\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.354491, "o", "\r\u001b[K\u001b[32m⠇\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.434951, "o", "\r\u001b[K\u001b[32m⠏\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.522794, "o", "\r\u001b[K\u001b[32m⠋\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.599657, "o", "\r\u001b[K\u001b[32m⠙\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.680358, "o", "\r\u001b[K\u001b[32m⠹\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.76119, "o", "\r\u001b[K\u001b[32m⠸\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.841677, "o", "\r\u001b[K\u001b[32m⠼\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[9.922625, "o", "\r\u001b[K\u001b[32m⠴\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[10.003351, "o", "\r\u001b[K\u001b[32m⠦\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[10.084105, "o", "\r\u001b[K\u001b[32m⠧\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[10.165211, "o", "\r\u001b[K\u001b[32m⠇\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[10.245939, "o", "\r\u001b[K\u001b[32m⠏\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[10.327597, "o", "\r\u001b[K\u001b[32m⠋\u001b[39m \u001b[33mConnecting MCP server 'hf' via url…\u001b(B\u001b[m"] +[10.398302, "o", "\r\u001b[K\u001b[32m⠙\u001b[39m\r\u001b[K\u001b[?12l\u001b[?25h"] +[10.398718, "o", "Connected MCP server 'hf' (url).\r\n"] +[10.398935, "o", "\u001b[2mAdded \u001b(B\u001b[m\u001b[96m\u001b[1m8\u001b(B\u001b[m\u001b[2m tools and \u001b(B\u001b[m\u001b[96m\u001b[1m4\u001b(B\u001b[m\u001b[2m prompts.\r\n\u001b(B\u001b[m"] +[10.406729, "o", "\r\n\u001b[J\u001b[A\u001b[K\u001b[93m❯\u001b[C\u001b(B\u001b[m"] +[10.413923, "o", "\u001b[22;1H\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[14;3H\u001b(B\u001b[m"] +[16.547165, "o", "/"] +[16.557532, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[16;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[17;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;1H\u001b[32m\u001b[40m tools \u001b[34m List tools \u001b[38;5;16m\u001b[48;5;248m \u001b[19;1H\u001b[32m\u001b[40m model \u001b[34m Inspect/switch models and update runtime settings (/model reasoning|task_budget|... \u001b[38;5;16m\u001b[48;5;248m \u001b[20;1H\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills ... \u001b[38;5;16m\u001b[48;5;248m \u001b[21;1H\u001b[32m\u001b[40m cards \u001b[34m Manage card packs (/cards, /cards add, /cards remove, /cards update, /cards publ... \u001b[38;5;16m\u001b[48;5;248m \u001b[14;4H\u001b(B\u001b[m"] +[16.585795, "o", "m\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[14;5H"] +[16.592453, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[16;1H\u001b[32m\u001b[40m model \u001b[34m Inspect/switch models and update runtime settings (/model reasoning|task_budget|... \u001b[38;5;16m\u001b[48;5;238m \u001b[17;1H\u001b[32m\u001b[40m markdown \u001b[34m Show last assistant message without markdown formatting \u001b[38;5;16m\u001b[48;5;238m \u001b[14;5H\u001b(B\u001b[m"] +[16.625726, "o", "c\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[14;6H"] +[16.632277, "o", "\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[14;6H\u001b(B\u001b[m"] +[16.666878, "o", "p\u001b[15;2H\u001b[K\u001b[14;7H"] +[16.704112, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[16.70524, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mmcp\r\n\u001b(B\u001b[m"] +[16.706483, "o", "\r\n"] +[16.706948, "o", "\u001b[36m▎\u001b[2m▼\u001b(B\u001b[m\u001b[36m [ 1] \u001b[94m\u001b[1mhf\u001b(B\u001b[m \u001b[2m─────────────────────────────────────────────────────────────────────────────────────\u001b[17;1H\u001b(B\u001b[m\r\n"] +[16.707449, "o", " \u001b[2mname : \u001b(B\u001b[m@huggingface/mcp-services\u001b[2m version : \u001b(B\u001b[m0.3.17\r\n"] +[16.70764, "o", " \u001b[2msession : \u001b(B\u001b[m\u001b[32mf8e5bcd6-f...cf36bead01\r\n\u001b(B\u001b[m"] +[16.707784, "o", " \u001b[2msessions : not advertised\r\n\u001b(B\u001b[m"] +[16.708035, "o", " \u001b[2mhealth : pending | interval: 30s | misses: 0/3 | last ok: never\r\n\u001b(B\u001b[m"] +[16.708105, "o", "\n\u001b[K"] +[16.70836, "o", "\n\u001b[A \u001b[2mlast activity: 6s ago\u001b(B\u001b[m\r\n\u001b[K"] +[16.708597, "o", "\n\u001b[A \u001b[2mmcp calls: \u001b(B\u001b[minitialize:1, prompts/list:1, resources/list:1, tools/list:1\r\n\u001b[K"] +[16.70877, "o", "\n\u001b[K"] +[16.708973, "o", "\u001b[2m┌ HTTP ────────────────────────────────────────\u001b(B\u001b[m\r\n\u001b[21;48H\u001b[2m req resp notif ping\u001b(B\u001b[m\r\n\u001b[K"] +[16.709064, "o", "\u001b[2m│\u001b(B\u001b[m\r\n\u001b[K"] +[16.709566, "o", "\u001b[2m│ \u001b(B\u001b[m\u001b[36m◁\u001b[39m\u001b[2m GET (SSE) 10m \u001b[37m\u001b[1m··················\u001b(B\u001b[m\u001b[94m\u001b[1m▽\u001b[37m\u001b[2m·\u001b(B\u001b[m\r\n\u001b[21;42H\u001b[2m now - - - -\u001b(B\u001b[m\r\n\u001b[K"] +[16.710086, "o", "\u001b[2m│ \u001b[30m▶\u001b[39m POST (SSE) 10m \u001b[30m····················\u001b(B\u001b[m\r\n\u001b[21;42H\u001b[2m now - - - -\u001b(B\u001b[m\r\n\u001b[K"] +[16.710589, "o", "\u001b[2m│ \u001b(B\u001b[m\u001b[92m▶\u001b[39m POST (JSON) \u001b[2m10m \u001b[37m\u001b[1m··················\u001b(B\u001b[m\u001b[34m\u001b[1m▼\u001b[37m\u001b[2m·\u001b(B\u001b[m\r\n\u001b[21;42H\u001b[2m now \u001b(B\u001b[m 0\u001b[2m \u001b(B\u001b[m 4\u001b[2m \u001b(B\u001b[m 0\u001b[2m -\u001b(B\u001b[m\r\n\u001b[K"] +[16.711054, "o", "\u001b[2m│ ● HEALTH 10m \u001b[37m\u001b[1m····················\u001b(B\u001b[m\r\n\u001b[21;42H\u001b[2m now - - - -\u001b(B\u001b[m\r\n\u001b[K"] +[16.711258, "o", "\u001b[2m│\u001b(B\u001b[m\r\n\u001b[K"] +[16.711638, "o", "\u001b[2m└ legend: \u001b(B\u001b[m\u001b[91m\u001b[1m●\u001b(B\u001b[m\u001b[2m error \u001b(B\u001b[m\u001b[34m\u001b[1m▼\u001b(B\u001b[m\u001b[2m response \u001b(B\u001b[m\u001b[33m\u001b[1m◆\u001b(B\u001b[m\u001b[2m request \u001b(B\u001b[m\u001b[36m\u001b[1m●\u001b(B\u001b[m\u001b[2m notification \u001b[32m●\u001b[39m ping \u001b[37m·\u001b(B\u001b[m\r\n\u001b[21;63H\u001b[2m idle\u001b(B\u001b[m\r\n\u001b[K\n\u001b[K"] +[16.711879, "o", "\n\u001b[A \u001b[92mSEP-2640 Skills over MCP is enabled: use `/skills registry` to select them\u001b[39m\r\n\u001b[K"] +[16.712416, "o", "\u001b[2m─| \u001b(B\u001b[m\u001b[92m\u001b[7mTo\u001b(B\u001b[m \u001b[92m\u001b[7mPr\u001b(B\u001b[m \u001b[92m\u001b[7mRe\u001b(B\u001b[m \u001b[2mRs\u001b(B\u001b[m \u001b[2mLo\u001b(B\u001b[m \u001b[2mCo\u001b(B\u001b[m \u001b[2mEx\u001b(B\u001b[m \u001b[92m\u001b[7mIn\u001b(B\u001b[m \u001b[92m\u001b[7mSk\u001b(B\u001b[m \u001b[2mUi\u001b(B\u001b[m \u001b[2m• Ro\u001b(B\u001b[m \u001b[92m\u001b[7mEl\u001b(B\u001b[m \u001b[92m\u001b[7mSa\u001b(B\u001b[m \u001b[2mSp |────────────────────────────────────────────────\u001b(B\u001b[m\u001b[22;1H\n\u001b[K\n\u001b[K"] +[16.754412, "o", "\u001b[K\u001b[93m❯\u001b[39m\r\u001b[3S\u001b[2A\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[19;3H"] +[16.760327, "o", "\u001b[22;1H\n\u001b[K\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[18;3H\u001b(B\u001b[m"] +[19.712937, "o", "/"] +[19.722356, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;248m \u001b[18;4H\u001b(B\u001b[m"] +[19.752176, "o", "s\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;5H"] +[19.762242, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills r... \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m system \u001b[34m Show the current system prompt \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m session \u001b[34m Manage sessions (/session list|new|resume|title|fork|delete|pin|export) \u001b[38;5;16m\u001b[48;5;238m \u001b[18;5H\u001b(B\u001b[m"] +[19.793815, "o", "k\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;6H"] +[19.803593, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;6H\u001b(B\u001b[m"] +[19.833632, "o", "i\r\n \u001b[K\u001b[18;7H"] +[19.843772, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;7H\u001b(B\u001b[m"] +[19.873422, "o", "l\r\n \u001b[K\u001b[18;8H"] +[19.879444, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;8H\u001b(B\u001b[m"] +[19.9166, "o", "l\r\n \u001b[K\u001b[18;9H"] +[19.924574, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;9H\u001b(B\u001b[m"] +[19.956312, "o", "s\r\n \u001b[K\u001b[18;10H"] +[19.998509, "o", "\u001b[C"] +[20.004384, "o", "\n\u001b[32m\u001b[40m list \u001b[34m List local skills \u001b[38;5;16m\u001b[48;5;238m \u001b[20;11H\u001b[32m\u001b[40m available \u001b[34m Browse marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[21;11H\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;248m \u001b[18;11H\u001b(B\u001b[m"] +[20.038736, "o", "r\u001b[19;2H\u001b[K\n\u001b[K\n\u001b[K\u001b[18;12H"] +[20.044567, "o", "\n\u001b[32m\u001b[40m remove \u001b[34m Remove a local skill \u001b[38;5;16m\u001b[48;5;238m \u001b[20;12H\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;12H\u001b(B\u001b[m"] +[20.080034, "o", "e\u001b[19;2H\u001b[K\n\u001b[K\u001b[18;13H"] +[20.089361, "o", "\n\u001b[32m\u001b[40m remove \u001b[34m Remove a local skill \u001b[38;5;16m\u001b[48;5;238m \u001b[20;13H\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;13H\u001b(B\u001b[m"] +[20.125471, "o", "g\u001b[19;2H\u001b[K\n\u001b[K\u001b[18;14H"] +[20.144049, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;14H\u001b(B\u001b[m"] +[20.16369, "o", "i\u001b[19;2H\u001b[K\u001b[18;15H"] +[20.169405, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;15H\u001b(B\u001b[m"] +[20.205972, "o", "s\u001b[19;2H\u001b[K\u001b[18;16H"] +[20.215592, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;16H\u001b(B\u001b[m"] +[20.247485, "o", "t\u001b[19;2H\u001b[K\u001b[18;17H"] +[20.26289, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;17H\u001b(B\u001b[m"] +[20.288792, "o", "r\u001b[19;2H\u001b[K\u001b[18;18H"] +[20.306769, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;18H\u001b(B\u001b[m"] +[20.330753, "o", "y\u001b[19;2H\u001b[K\u001b[18;19H"] +[20.378258, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[20.379195, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mskills\u001b[39m registry\r\n\u001b(B\u001b[m"] +[20.743719, "o", "\u001b[2mConfigured registries:\r\n\u001b[36m[1] \u001b(B\u001b[m\u001b[94m\u001b[1mhttps://github.com/fast-agent-ai/skills\u001b(B\u001b[m\u001b[2m •\u001b(B\u001b[m\r\u001b[6S\u001b[14;46H\u001b[2m \u001b[32mcurrent\r\n\u001b[36m[2] \u001b(B\u001b[m\u001b[94m\u001b[1mhttps://github.com/huggingface/skills\r\n\u001b(B\u001b[m\u001b[36m\u001b[2m[3] \u001b(B\u001b[m\u001b[94m\u001b[1mhttps://github.com/anthropics/skills\u001b(B\u001b[m\r\n\u001b[K\r\n\u001b[2mMCP registries:\u001b(B\u001b[m\u001b[K\r\n\u001b[36m\u001b[2m[4] \u001b(B\u001b[m\u001b[94m\u001b[1mmcp-server hf@0.3.17\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[2mUsage: /skills registry \u001b(B\u001b[m\u001b[K\r\n\u001b[K"] +[20.755083, "o", "\u001b[K\u001b[93m❯\u001b[39m\r\u001b[3S\u001b[2A\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[19;3H"] +[20.763386, "o", "\u001b[22;1H\n\u001b[K\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[18;3H\u001b(B\u001b[m"] +[23.382152, "o", "/"] +[23.397444, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;248m \u001b[18;4H\u001b(B\u001b[m"] +[23.427489, "o", "s\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;5H"] +[23.442139, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills r... \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m system \u001b[34m Show the current system prompt \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m session \u001b[34m Manage sessions (/session list|new|resume|title|fork|delete|pin|export) \u001b[38;5;16m\u001b[48;5;238m \u001b[18;5H\u001b(B\u001b[m"] +[23.467714, "o", "k\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;6H"] +[23.479189, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;6H\u001b(B\u001b[m"] +[23.5074, "o", "i\r\n \u001b[K\u001b[18;7H"] +[23.514816, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;7H\u001b(B\u001b[m"] +[23.554381, "o", "l\r\n \u001b[K\u001b[18;8H"] +[23.560428, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;8H\u001b(B\u001b[m"] +[23.623437, "o", "l\r\n \u001b[K\u001b[18;9H"] +[23.629778, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;9H\u001b(B\u001b[m"] +[23.632306, "o", "s\r\n \u001b[K\u001b[18;10H"] +[23.6757, "o", "\u001b[C"] +[23.693613, "o", "\n\u001b[32m\u001b[40m list \u001b[34m List local skills \u001b[38;5;16m\u001b[48;5;238m \u001b[20;11H\u001b[32m\u001b[40m available \u001b[34m Browse marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[21;11H\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;248m \u001b[18;11H\u001b(B\u001b[m"] +[23.716171, "o", "r\u001b[19;2H\u001b[K\n\u001b[K\n\u001b[K\u001b[18;12H"] +[23.734281, "o", "\n\u001b[32m\u001b[40m remove \u001b[34m Remove a local skill \u001b[38;5;16m\u001b[48;5;238m \u001b[20;12H\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;12H\u001b(B\u001b[m"] +[23.755586, "o", "e\u001b[19;2H\u001b[K\n\u001b[K\u001b[18;13H"] +[23.762928, "o", "\n\u001b[32m\u001b[40m remove \u001b[34m Remove a local skill \u001b[38;5;16m\u001b[48;5;238m \u001b[20;13H\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;13H\u001b(B\u001b[m"] +[23.797109, "o", "g\u001b[19;2H\u001b[K\n\u001b[K\u001b[18;14H"] +[23.805732, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;14H\u001b(B\u001b[m"] +[23.837396, "o", "i\u001b[19;2H\u001b[K\u001b[18;15H"] +[23.846086, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;15H\u001b(B\u001b[m"] +[23.878438, "o", "s\u001b[19;2H\u001b[K\u001b[18;16H"] +[23.8929, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;16H\u001b(B\u001b[m"] +[23.925752, "o", "t\u001b[19;2H\u001b[K\u001b[18;17H"] +[23.937717, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;17H\u001b(B\u001b[m"] +[23.968276, "o", "r\u001b[19;2H\u001b[K\u001b[18;18H"] +[24.002587, "o", "\n\u001b[32m\u001b[40m registry \u001b[34m Set skills registry \u001b[38;5;16m\u001b[48;5;238m \u001b[18;18H\u001b(B\u001b[m"] +[24.010715, "o", "y\u001b[19;2H\u001b[K\u001b[18;19H"] +[24.052025, "o", "\u001b[C"] +[24.094381, "o", "h"] +[24.13766, "o", "f"] +[24.179252, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[24.304973, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mskills\u001b[39m registry hf\r\n\u001b(B\u001b[m"] +[24.569632, "o", "\u001b[32mRegistry set to: mcp-server hf@0.3.17\r\n\u001b[39m\u001b[2mSkills discovered: 15\r\n\u001b(B\u001b[m"] +[24.578953, "o", "\r\n\u001b[J\u001b[A\u001b[K\u001b[93m❯\u001b[39m\r\u001b[2S\u001b[K\r\n\u001b[K\u001b[19;3H"] +[24.584602, "o", "\u001b[22;1H\n\u001b[K\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[18;3H\u001b(B\u001b[m"] +[27.187257, "o", "/"] +[27.19374, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;248m \u001b[18;4H\u001b(B\u001b[m"] +[27.227469, "o", "s\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;5H"] +[27.234016, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills r... \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m system \u001b[34m Show the current system prompt \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m session \u001b[34m Manage sessions (/session list|new|resume|title|fork|delete|pin|export) \u001b[38;5;16m\u001b[48;5;238m \u001b[18;5H\u001b(B\u001b[m"] +[27.266486, "o", "k\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;6H"] +[27.273341, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;6H\u001b(B\u001b[m"] +[27.309773, "o", "i\r\n \u001b[K\u001b[18;7H"] +[27.322503, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;7H\u001b(B\u001b[m"] +[27.346842, "o", "l\r\n \u001b[K\u001b[18;8H"] +[27.353467, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;8H\u001b(B\u001b[m"] +[27.386485, "o", "l\r\n \u001b[K\u001b[18;9H"] +[27.392936, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;9H\u001b(B\u001b[m"] +[27.426786, "o", "s\r\n \u001b[K\u001b[18;10H"] +[27.467612, "o", "\u001b[C"] +[27.474717, "o", "\n\u001b[32m\u001b[40m list \u001b[34m List local skills \u001b[38;5;16m\u001b[48;5;238m \u001b[20;11H\u001b[32m\u001b[40m available \u001b[34m Browse marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[21;11H\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;248m \u001b[18;11H\u001b(B\u001b[m"] +[27.507886, "o", "s\u001b[19;2H\u001b[K\n\u001b[K\n\u001b[K\u001b[18;12H"] +[27.515399, "o", "\n\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[18;12H\u001b(B\u001b[m"] +[27.548441, "o", "e\u001b[19;2H\u001b[K\u001b[18;13H"] +[27.554486, "o", "\n\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[18;13H\u001b(B\u001b[m"] +[27.589281, "o", "a\u001b[19;2H\u001b[K\u001b[18;14H"] +[27.596555, "o", "\n\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[18;14H\u001b(B\u001b[m"] +[27.627725, "o", "r\u001b[19;2H\u001b[K\u001b[18;15H"] +[27.632976, "o", "\n\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[18;15H\u001b(B\u001b[m"] +[27.668182, "o", "c\u001b[19;2H\u001b[K\u001b[18;16H"] +[27.676489, "o", "\n\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[18;16H\u001b(B\u001b[m"] +[27.71533, "o", "h\u001b[19;2H\u001b[K\u001b[18;17H"] +[27.75516, "o", "\u001b[C"] +[27.765171, "o", "\n\u001b[32m\u001b[40m \u001b[34m filter marketplace skills by name/description \u001b[38;5;16m\u001b[48;5;238m \u001b[20;18H\u001b[32m\u001b[40m (empty) \u001b[34m no arg lists all; run /skills available to browse \u001b[38;5;16m\u001b[48;5;238m \u001b[18;18H\u001b(B\u001b[m"] +[27.797376, "o", "d\u001b[19;2H\u001b[K\n\u001b[K\u001b[18;19H"] +[27.836333, "o", "a"] +[27.879418, "o", "t"] +[27.918529, "o", "a"] +[27.960044, "o", "s"] +[27.999238, "o", "e"] +[28.038973, "o", "t"] +[28.079494, "o", "\u001b[C"] +[28.119326, "o", "v"] +[28.1583, "o", "i"] +[28.197436, "o", "e"] +[28.238217, "o", "w"] +[28.278299, "o", "e"] +[28.318165, "o", "r"] +[28.360794, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[28.362499, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mskills\u001b[39m search dataset viewer\r\n\u001b(B\u001b[m"] +[28.608797, "o", "\u001b[7S\u001b[7A\u001b[1mMCP skills from mcp-server hf@0.3.17 (search: dataset viewer):\u001b[14;1H\u001b(B\u001b[m\u001b[36m\u001b[2m[ 1] \u001b(B\u001b[m\u001b[94m\u001b[1mhuggingface-datasets\r\n\u001b(B\u001b[m Use this skill for Hugging Face Dataset Viewer API workflows that fetch\r\n subset/split metadata, paginate rows, search text, apply filters,\u001b[K\r\n download parquet URLs, and read size or statistics.\u001b[K\r\n\u001b[2m \u001b[32msource: skill://huggingface-datasets/SKILL.md\u001b(B\u001b[m\u001b[K\r\n\u001b[2m \u001b[32mintegrity: SHA256 checked\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[K\r\n\u001b[K\n\u001b[A\u001b[36mInstall with `/skills add `.\u001b[39m\r\n\u001b[K"] +[28.609137, "o", "\n\u001b[A\u001b[36mSearch with `/skills search `.\u001b[39m\r\n\u001b[K"] +[28.622807, "o", "\u001b[K\u001b[93m❯\u001b[39m\r\u001b[3S\u001b[2A\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[19;3H"] +[28.629215, "o", "\u001b[22;1H\n\u001b[K\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[18;3H\u001b(B\u001b[m"] +[31.373305, "o", "/"] +[31.386071, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;248m \u001b[18;4H\u001b(B\u001b[m"] +[31.414036, "o", "s\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;5H"] +[31.421685, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills r... \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m system \u001b[34m Show the current system prompt \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m session \u001b[34m Manage sessions (/session list|new|resume|title|fork|delete|pin|export) \u001b[38;5;16m\u001b[48;5;238m \u001b[18;5H\u001b(B\u001b[m"] +[31.45602, "o", "k\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;6H"] +[31.463758, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;6H\u001b(B\u001b[m"] +[31.497553, "o", "i\r\n \u001b[K\u001b[18;7H"] +[31.508404, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;7H\u001b(B\u001b[m"] +[31.538045, "o", "l\r\n \u001b[K\u001b[18;8H"] +[31.544853, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;8H\u001b(B\u001b[m"] +[31.582849, "o", "l\r\n \u001b[K\u001b[18;9H"] +[31.592574, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;9H\u001b(B\u001b[m"] +[31.623094, "o", "s\r\n \u001b[K\u001b[18;10H"] +[31.668256, "o", "\u001b[C"] +[31.683616, "o", "\n\u001b[32m\u001b[40m list \u001b[34m List local skills \u001b[38;5;16m\u001b[48;5;238m \u001b[20;11H\u001b[32m\u001b[40m available \u001b[34m Browse marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[21;11H\u001b[32m\u001b[40m search \u001b[34m Search marketplace skills \u001b[38;5;16m\u001b[48;5;248m \u001b[18;11H\u001b(B\u001b[m"] +[31.7082, "o", "a\u001b[19;2H\u001b[K\n\u001b[K\n\u001b[K\u001b[18;12H"] +[31.716453, "o", "\n\u001b[32m\u001b[40m available \u001b[34m Browse marketplace skills \u001b[38;5;16m\u001b[48;5;238m \u001b[20;12H\u001b[32m\u001b[40m add \u001b[34m Install a skill \u001b[38;5;16m\u001b[48;5;238m \u001b[18;12H\u001b(B\u001b[m"] +[31.749476, "o", "d\u001b[19;2H\u001b[K\n\u001b[K\u001b[18;13H"] +[31.758397, "o", "\n\u001b[32m\u001b[40m add \u001b[34m Install a skill \u001b[38;5;16m\u001b[48;5;238m \u001b[18;13H\u001b(B\u001b[m"] +[31.787558, "o", "d\u001b[19;2H\u001b[K\u001b[18;14H"] +[31.826701, "o", "\u001b[C"] +[31.836046, "o", "\n\u001b[32m\u001b[40m (empty) \u001b[34m press Enter with no argument to pick from a list \u001b[38;5;16m\u001b[48;5;238m \u001b[20;15H\u001b[32m\u001b[40m \u001b[34m by index — marketplace skill \u001b[38;5;16m\u001b[48;5;238m \u001b[21;15H\u001b[32m\u001b[40m \u001b[34m by name — marketplace skill \u001b[38;5;16m\u001b[48;5;248m \u001b[18;15H\u001b(B\u001b[m"] +[31.866945, "o", "h\u001b[19;2H\u001b[K\n\u001b[K\n\u001b[K\u001b[18;16H"] +[31.911758, "o", "u"] +[31.95046, "o", "g"] +[31.98912, "o", "g"] +[32.029306, "o", "i"] +[32.068075, "o", "n"] +[32.108374, "o", "g"] +[32.14815, "o", "f"] +[32.187365, "o", "a"] +[32.226977, "o", "c"] +[32.308079, "o", "e-"] +[32.346405, "o", "d"] +[32.386901, "o", "a"] +[32.425862, "o", "t"] +[32.465351, "o", "a"] +[32.504262, "o", "s"] +[32.543252, "o", "e"] +[32.581738, "o", "t"] +[32.621568, "o", "s"] +[32.658727, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[32.659777, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mskills\u001b[39m add huggingface-datasets\r\n\u001b(B\u001b[m"] +[33.158971, "o", "\u001b[32mInstalled skill: huggingface-datasets\r\n\u001b[2mlocation: /tmp/tmp.UdUUQNuO1o/skills/huggingface-datasets\r\n\u001b(B\u001b[m"] +[33.167769, "o", "\r\n\u001b[J\u001b[A\u001b[K\u001b[93m❯\u001b[39m\r\u001b[2S\u001b[K\r\n\u001b[K\u001b[19;3H"] +[33.173292, "o", "\u001b[22;1H\n\u001b[K\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[18;3H\u001b(B\u001b[m"] +[35.668288, "o", "/"] +[35.674235, "o", "\r\n\u001b[32m\u001b[40m mcp \u001b[34m Manage MCP runtime servers (/mcp list|connect|disconnect|reconnect|session) \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m connect \u001b[34m Alias for /mcp connect with target auto-detection \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m history \u001b[34m Show conversation history overview (or /history show|detail|save|load|clear|rewi... \u001b[38;5;16m\u001b[48;5;248m \u001b[18;4H\u001b(B\u001b[m"] +[35.707448, "o", "s\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;5H"] +[35.715269, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills r... \u001b[38;5;16m\u001b[48;5;238m \u001b[20;1H\u001b[32m\u001b[40m system \u001b[34m Show the current system prompt \u001b[38;5;16m\u001b[48;5;238m \u001b[21;1H\u001b[32m\u001b[40m session \u001b[34m Manage sessions (/session list|new|resume|title|fork|delete|pin|export) \u001b[38;5;16m\u001b[48;5;238m \u001b[18;5H\u001b(B\u001b[m"] +[35.748627, "o", "k\r\n \u001b[K\r\n \u001b[K\r\n \u001b[K\u001b[18;6H"] +[35.757669, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;6H\u001b(B\u001b[m"] +[35.791436, "o", "i\r\n \u001b[K\u001b[18;7H"] +[35.798322, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;7H\u001b(B\u001b[m"] +[35.836556, "o", "l\r\n \u001b[K\u001b[18;8H"] +[35.849609, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;8H\u001b(B\u001b[m"] +[35.879975, "o", "l\r\n \u001b[K\u001b[18;9H"] +[35.89284, "o", "\r\n\u001b[32m\u001b[40m skills \u001b[34m Manage skills (/skills, /skills available, /skills search, /skills add, /skills re... \u001b[38;5;16m\u001b[48;5;238m \u001b[18;9H\u001b(B\u001b[m"] +[35.92911, "o", "s\r\n \u001b[K\u001b[18;10H"] +[35.966965, "o", "\r\n\u001b[J\u001b[A\u001b[K"] +[35.968291, "o", "\u001b[2m❯ \u001b[35m/\u001b[95mskills\r\n\u001b(B\u001b[m"] +[35.971963, "o", "\u001b[12S\u001b[7d\u001b[1mSkills in .agents/skills:\u001b[9;1H\u001b(B\u001b[m\u001b[33mNo skills in this directory\u001b[11;1H\u001b[39m\u001b[1mSkills in .claude/skills:\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[33mNo skills in this directory\u001b[39m\u001b[K\r\n\u001b[K\r\n\u001b[1mSkills in /tmp/tmp.UdUUQNuO1o/skills:\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[36m\u001b[2m[ 1] \u001b(B\u001b[m\u001b[94m\u001b[1mhuggingface-datasets\u001b(B\u001b[m\u001b[K\r\n Use this skill for Hugging Face Dataset Viewer API workflows that fetch\u001b[K\r\n subset/split metadata, paginate rows, search text, apply filters,\u001b[K\r\n download parquet URLs, and read size or statistics.\u001b[K\r\n\u001b[2m \u001b[32msource: /tmp/tmp.UdUUQNuO1o/skills/huggingface-datasets\u001b(B\u001b[m\u001b[K\r\n\u001b[2m provenance: mcp-server hf@0.3.17 (skill://huggingface-datasets/SKILL.md) \u001b(B\u001b[m\u001b[K\u001b[2m•\u001b(B\u001b[m\r\u001b[8S\u001b[14;80H\u001b[2m integrity: \r\nsha256\u001b(B\u001b[m\u001b[K\r\n\u001b[2m installed: 2026-06-02 15:12:50 revision: \u001b(B\u001b[m\u001b[K\r\n\u001b[2msha256:eeca50adf211ea64a288b852938f85a5183b045c720dc696b62db9aa2ea8deb2\u001b(B\u001b[m\u001b[K\r\n\u001b[K\r\n\u001b[2mBrowse marketplace skills with /skills available\u001b(B\u001b[m\u001b[K\r\n\u001b[2mSearch marketplace skills with /skills search \u001b(B\u001b[m\u001b[K\r\n\u001b[2mRemove a skill with /skills remove \u001b(B\u001b[m\u001b[K\r\n\u001b[K"] +[35.982261, "o", "\u001b[K\u001b[93m❯\u001b[39m\r\u001b[3S\u001b[2A\u001b[K\r\n\u001b[K\r\n\u001b[K\u001b[19;3H"] +[35.987374, "o", "\u001b[22;1H\n\u001b[K\u001b[30m\u001b[47m\u001b[7m \u001b[34m\u001b[40m agent \u001b[30m\u001b[47m \u001b[32m\u001b[40mT\u001b[30m\u001b[107mVD\u001b[100m ▲ \u001b[47m \u001b[42mpassthrough\u001b[47m | 0.00% | \u001b[32m\u001b[40m NRML \u001b[30m\u001b[47m | fast-agent-skills-mcp.VHo0tO | fast-agent 0.7.15\u001b[18;3H\u001b(B\u001b[m"] diff --git a/docs/docs/guides/skills.md b/docs/docs/guides/skills.md index 1b4cc9a1e..82b8b5007 100644 --- a/docs/docs/guides/skills.md +++ b/docs/docs/guides/skills.md @@ -26,6 +26,11 @@ When valid `SKILL.md` files are found: Skills installed from any git repo (local or remote) can be automatically updated. +!!! note "Skills over MCP" + + Thanks to Ola Hungerford, fast-agent has early access support for MCP SEP-2640: [Skills over MCP](../mcp/skills-over-mcp.md) + + ## Installing Skills Skills can be installed, updated and removed from the CLI or TUI with the `skills` command. diff --git a/docs/docs/mcp/skills-over-mcp.md b/docs/docs/mcp/skills-over-mcp.md new file mode 100644 index 000000000..911ec978d --- /dev/null +++ b/docs/docs/mcp/skills-over-mcp.md @@ -0,0 +1,80 @@ +--- +title: Skills over MCP +social: + title: Skills over MCP + tagline: Install Agent Skills from MCP servers that implement SEP-2640. + description: Install Agent Skills from MCP servers that implement SEP-2640. + alt: fast-agent social card - Skills over MCP +--- + +`fast-agent` supports the registry and installation portion of +[SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/93d7a9ddb20d4b3594f4a1be7508ee47f0718f17/seps/2640-skills-extension.md). +SEP-2640 is still a draft, so this support is intentionally scoped: an MCP +server can act as a skills registry when it advertises the +`io.modelcontextprotocol/skills` extension capability, and `fast-agent` installs +selected skills into the normal managed skills directory. + +When a connected MCP server advertises this capability, `fast-agent` shows it as +an MCP-backed skills registry. Opening `/skills registry` reads +`skill://index.json` and lists installable `skill-md` or archive entries that +include a valid `sha256:` digest. Installing a selected entry downloads the +artifact, verifies its SHA-256, then writes the skill into the normal managed +skills directory. Installed skills then behave like other local skills and +include MCP server provenance plus the verified artifact digest in their sidecar +metadata. + +## Trying it + +Run or connect to a SEP-2640-enabled MCP server. This example uses the hosted +Hugging Face MCP Server: + +```text +/mcp connect --name hf https://huggingface.co/mcp +/mcp +/skills registry +/skills registry hf +/skills available +/skills add +``` + +`/mcp` shows when SEP-2640 Skills over MCP is enabled and points you to +`/skills registry` to select the MCP server as the current install source. +Listings show `integrity: SHA256 checked` for installable MCP skills. + +
+
+ + + +
+
+
+ + + +## Current scope + +This implementation uses MCP as a registry for installation. It does not expose +MCP-served skill resources directly to the model, and it does not make active +skills read supporting files from the MCP server. That deeper resource-loading +workflow is planned separately. + +`/skills update` can compare the installed artifact digest with the current MCP +registry digest and apply a verified update when the server publishes a newer +artifact. The top-level `fast-agent skills` CLI remains marketplace/file/GitHub +oriented; select MCP registries from an interactive session after connecting the +MCP server. diff --git a/docs/docs/ref/docs_automation.md b/docs/docs/ref/docs_automation.md index 634226166..b799fee7f 100644 --- a/docs/docs/ref/docs_automation.md +++ b/docs/docs/ref/docs_automation.md @@ -61,6 +61,7 @@ uv run scripts/docs.py cast-build tui-shell uv run scripts/docs.py cast-build model-picker uv run scripts/docs.py cast-build skills-direct-install uv run scripts/docs.py cast-build skills-slash-commands +uv run scripts/docs.py cast-build skills-over-mcp ``` The `tui-shell` scenario records `fast-agent` starting with shell access and a visible model @@ -71,6 +72,9 @@ The `model-picker` scenario records `fast-agent go` opening the startup model pi the provider/model lists with arrow keys. The skills scenarios record direct install and update-check flows from a temporary local git repository, using either CLI commands or `/skills` slash commands in the TUI. +The `skills-over-mcp` scenario connects to `https://huggingface.co/mcp`, selects the `hf` +MCP-backed registry, searches the registry, and installs a SHA256-verified skill. Override +`FAST_AGENT_SKILLS_MCP_DEMO_SERVER` to record against a local SEP-2640 server. By default, cast recordings stop by killing the tmux session after the final demonstrated action, so the user-facing recording does not show a trailing `/exit`. Set diff --git a/docs/social_cards/contact-sheet.html b/docs/social_cards/contact-sheet.html index 0826d1683..1bfe44b8c 100644 --- a/docs/social_cards/contact-sheet.html +++ b/docs/social_cards/contact-sheet.html @@ -99,7 +99,7 @@

fast-agent social cards

Generated review sheet for committed Open Graph/Twitter images.

-
64 cards · 1200×630px target · 1000 KB max
+
65 cards · 1200×630px target · 1000 KB max
@@ -180,7 +180,7 @@

Python API

Badge
AGENTS
Theme
doc / workflow
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -195,7 +195,7 @@

Files and Resources

Badge
AGENTS
Theme
section / workflow
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -225,7 +225,7 @@

Command Plugins

Badge
AGENTS
Theme
doc / workflow
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -255,7 +255,7 @@

Deploy and Run Agents

Badge
AGENTS
Theme
doc / workflow
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -285,7 +285,7 @@

Agent Workflows

Badge
AGENTS
Theme
doc / workflow
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -325,7 +325,7 @@

Getting Started

Badge
DOCS
Theme
doc / protocol-grid
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -340,7 +340,7 @@

fast-agent

Badge
FAST-AGENT
Theme
hero / protocol-grid
Status
ok
-
Size
1200×630 · 32 KB
+
Size
1200×630 · 31 KB
@@ -360,7 +360,7 @@

Batch Processing

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -375,7 +375,7 @@

Code with OpenAI Codex

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -390,7 +390,7 @@

Core Concepts Guide

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -405,7 +405,7 @@

Code with Hugging Face

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -420,7 +420,7 @@

Privacy-filtered exports

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 28 KB
+
Size
1200×630 · 26 KB
@@ -450,7 +450,7 @@

Structured Outputs

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -465,7 +465,7 @@

Using the TUI

Badge
GUIDE
Theme
doc / terminal
Status
ok
-
Size
1200×630 · 23 KB
+
Size
1200×630 · 22 KB
@@ -500,7 +500,7 @@

Configure MCP Servers

Badge
MCP
Theme
section / protocol-lines
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -530,7 +530,7 @@

Run an MCP Server

Badge
MCP
Theme
doc / protocol-lines
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -575,7 +575,7 @@

OpenAI Apps SDK

Badge
MCP
Theme
doc / protocol-lines
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -590,6 +590,21 @@

MCP Resources

Badge
MCP
Theme
doc / protocol-lines
Status
ok
+
Size
1200×630 · 24 KB
+ + + + +
+ Skills over MCP +
+

Skills over MCP

+
+
Source
mcp/skills-over-mcp.md
+
Output
docs/assets/social/mcp/skills-over-mcp.png
+
Badge
MCP
+
Theme
doc / protocol-lines
+
Status
ok
Size
1200×630 · 25 KB
@@ -605,7 +620,7 @@

MCP State Transfer

Badge
MCP
Theme
doc / protocol-lines
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -620,7 +635,7 @@

MCP Types

Badge
MCP
Theme
doc / protocol-lines
Status
ok
-
Size
1200×630 · 24 KB
+
Size
1200×630 · 23 KB
@@ -640,7 +655,7 @@

Providers and Models

Badge
MODELS
Theme
section / model-orbits
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -670,7 +685,7 @@

LLM Providers

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 24 KB
@@ -685,7 +700,7 @@

Model Overlays

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -700,7 +715,7 @@

Models Reference

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 25 KB
@@ -715,7 +730,7 @@

Additional Providers

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 28 KB
+
Size
1200×630 · 27 KB
@@ -730,7 +745,7 @@

Aliyun

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -760,7 +775,7 @@

Azure OpenAI

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -775,7 +790,7 @@

AWS Bedrock

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -790,7 +805,7 @@

DeepSeek

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -805,7 +820,7 @@

Generic OpenAI-compatible providers

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 30 KB
+
Size
1200×630 · 28 KB
@@ -820,7 +835,7 @@

Google

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 24 KB
+
Size
1200×630 · 23 KB
@@ -835,7 +850,7 @@

Groq

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 24 KB
+
Size
1200×630 · 23 KB
@@ -850,7 +865,7 @@

Hugging Face

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -865,7 +880,7 @@

OpenAI

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -880,22 +895,22 @@

OpenRouter

Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
- xAI / Grok + xAI
-

xAI / Grok

+

xAI

Source
models/providers/xai.md
Output
docs/assets/social/models/providers/xai.png
Badge
MODELS
Theme
doc / model-orbits
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 23 KB
@@ -915,7 +930,7 @@

About fast-agent

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -930,7 +945,7 @@

Azure OpenAI Configuration

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -945,7 +960,7 @@

Batch Processing (Reference)

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -960,7 +975,7 @@

FastAgent Class Reference

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -990,7 +1005,7 @@

Configuration Reference

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -1005,7 +1020,7 @@

Docs Automation

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 26 KB
+
Size
1200×630 · 25 KB
@@ -1035,7 +1050,7 @@

Authoritative Articles

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -1050,7 +1065,7 @@

Generated Reference Docs

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 27 KB
+
Size
1200×630 · 26 KB
@@ -1065,7 +1080,7 @@

fast-agent go

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -1080,7 +1095,7 @@

OpenTelemetry

Badge
REFERENCE
Theme
doc / index
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
@@ -1100,7 +1115,7 @@

Welcome

Badge
FAST-AGENT
Theme
section / protocol-grid
Status
ok
-
Size
1200×630 · 25 KB
+
Size
1200×630 · 24 KB
diff --git a/docs/zensical.toml b/docs/zensical.toml index 8458dd938..f724bb7f4 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -69,6 +69,7 @@ nav = [ }, { "MCP" = [ { "Configuring Servers" = "mcp/index.md" }, + { "Skills over MCP" = "mcp/skills-over-mcp.md" }, { "Deploying as an MCP Server" = "mcp/mcp-server.md" }, { "Inspecting Servers" = "mcp/mcp_display.md" }, { "Quickstart - Elicitations" = "mcp/elicitations.md" }, diff --git a/pyproject.toml b/pyproject.toml index e83cfef9a..efa8acd75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fast-agent-mcp" -version = "0.7.14" +version = "0.7.15" description = "Define, Prompt and Test MCP enabled Agents and Workflows" readme = "README.md" license = { file = "LICENSE" } diff --git a/scripts/docs_assets.py b/scripts/docs_assets.py index 313807647..db76f73ef 100644 --- a/scripts/docs_assets.py +++ b/scripts/docs_assets.py @@ -99,12 +99,30 @@ def _skills_slash_commands_scenario() -> TerminalCastScenario: ) +def _skills_over_mcp_scenario() -> TerminalCastScenario: + command = os.environ.get( + "FAST_AGENT_SKILLS_MCP_DEMO_COMMAND", + "fast-agent -x --model passthrough", + ) + return TerminalCastScenario( + name="skills-over-mcp", + title="fast-agent Skills over MCP", + output=ASSETS / "tui" / "skills-over-mcp.cast", + cols=int(os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_COLS", "96")), + rows=int(os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_ROWS", "22")), + idle_time_limit=float(os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_IDLE_TIME_LIMIT", "1.3")), + prompt="", + shell_command=command, + ) + + def _scenarios() -> dict[str, TerminalCastScenario]: scenarios = [ _tui_shell_scenario(), _model_picker_scenario(), _skills_direct_install_scenario(), _skills_slash_commands_scenario(), + _skills_over_mcp_scenario(), ] return {scenario.name: scenario for scenario in scenarios} @@ -135,7 +153,7 @@ def _model_picker_record_script(scenario: TerminalCastScenario) -> str: tmux kill-session -t "$SESSION" 2>/dev/null || true tmux new-session -d -s "$SESSION" -x {scenario.cols} -y {scenario.rows} \\ - "DEMO_FAST_AGENT_HOME=\\$(mktemp -d) && printf '{{}}\\n' > \\\"\\$DEMO_FAST_AGENT_HOME/fast-agent.yaml\\\" && export FAST_AGENT_HOME=\\\"\\$DEMO_FAST_AGENT_HOME\\\" && DEMO_WORKDIR=\\$(mktemp -d -t fast-agent-model-picker.XXXXXX) && cd \\\"\\$DEMO_WORKDIR\\\" && unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV FAST_AGENT_MODEL && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 bash --noprofile --norc" + "DEMO_FAST_AGENT_HOME=\\$(mktemp -d) && printf '{{}}\\n' > \\\"\\$DEMO_FAST_AGENT_HOME/fast-agent.yaml\\\" && export FAST_AGENT_HOME=\\\"\\$DEMO_FAST_AGENT_HOME\\\" && DEMO_WORKDIR=\\$(mktemp -d -t fast-agent-model-picker.XXXXXX) && cd \\\"\\$DEMO_WORKDIR\\\" && unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV FAST_AGENT_MODEL NO_COLOR && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 bash --noprofile --norc" tmux set-option -t "$SESSION" status off >/dev/null ( @@ -202,7 +220,7 @@ def _skills_direct_install_record_script(scenario: TerminalCastScenario) -> str: Use this skill to demonstrate direct installation from a local repository. SKILL git -C skill-repo init -q && git -C skill-repo config user.email docs-demo@example.com && git -C skill-repo config user.name 'Docs Demo' && git -C skill-repo add . && git -C skill-repo commit -q -m 'Initial demo skill' -unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 bash --noprofile --norc" +unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV NO_COLOR && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 bash --noprofile --norc" tmux set-option -t "$SESSION" status off >/dev/null ( @@ -275,7 +293,7 @@ def _skills_slash_commands_record_script(scenario: TerminalCastScenario) -> str: Use this skill to demonstrate /skills add and /skills update. SKILL git -C skill-repo init -q && git -C skill-repo config user.email docs-demo@example.com && git -C skill-repo config user.name 'Docs Demo' && git -C skill-repo add . && git -C skill-repo commit -q -m 'Initial demo skill' -unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 TUI__COMPLETION_MENU_RESERVED_LINES=${{TUI__COMPLETION_MENU_RESERVED_LINES:-4}} bash --noprofile --norc" +unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV NO_COLOR && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 TUI__COMPLETION_MENU_RESERVED_LINES=${{TUI__COMPLETION_MENU_RESERVED_LINES:-4}} bash --noprofile --norc" tmux set-option -t "$SESSION" status off >/dev/null ( @@ -309,6 +327,76 @@ def _skills_slash_commands_record_script(scenario: TerminalCastScenario) -> str: """ +def _skills_over_mcp_record_script(scenario: TerminalCastScenario) -> str: + startup_wait = os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_STARTUP_WAIT", "3.5") + connect_wait = os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_CONNECT_WAIT", "6.0") + command_wait = os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_COMMAND_WAIT", "1.4") + final_wait = os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_FINAL_WAIT", "1.0") + typing_delay = os.environ.get("FAST_AGENT_SKILLS_MCP_DEMO_TYPING_DELAY", "0.035") + server_url = os.environ.get( + "FAST_AGENT_SKILLS_MCP_DEMO_SERVER", + "https://huggingface.co/mcp", + ) + session = f"fast_agent_docs_{scenario.name.replace('-', '_')}" + command = scenario.shell_command.replace("'", "'\"'\"'") + server_url = server_url.replace("'", "'\"'\"'") + return f"""#!/usr/bin/env bash +set -euo pipefail + +SESSION='{session}' +ROOT='{ROOT}' + +type_slow() {{ + local target="$1" + local text="$2" + local delay="$3" + local i char + for (( i=0; i<${{#text}}; i++ )); do + char="${{text:i:1}}" + tmux send-keys -l -t "$target" "$char" + sleep "$delay" + done +}} + +tmux kill-session -t "$SESSION" 2>/dev/null || true +tmux new-session -d -s "$SESSION" -x {scenario.cols} -y {scenario.rows} \\ + "DEMO_FAST_AGENT_HOME=\\$(mktemp -d) && printf '{{}}\\n' > \\\"\\$DEMO_FAST_AGENT_HOME/fast-agent.yaml\\\" && export FAST_AGENT_HOME=\\\"\\$DEMO_FAST_AGENT_HOME\\\" && DEMO_WORKDIR=\\$(mktemp -d -t fast-agent-skills-mcp.XXXXXX) && cd \\\"\\$DEMO_WORKDIR\\\" && unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV NO_COLOR && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 TUI__COMPLETION_MENU_RESERVED_LINES=${{TUI__COMPLETION_MENU_RESERVED_LINES:-4}} bash --noprofile --norc" +tmux set-option -t "$SESSION" status off >/dev/null + +( + sleep 1 + type_slow "$SESSION" '{command}' 0.035 + tmux send-keys -t "$SESSION" Enter + sleep {startup_wait} + type_slow "$SESSION" '/mcp connect {server_url} --name hf' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {connect_wait} + type_slow "$SESSION" '/mcp' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {command_wait} + type_slow "$SESSION" '/skills registry' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {command_wait} + type_slow "$SESSION" '/skills registry hf' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {command_wait} + type_slow "$SESSION" '/skills search dataset viewer' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {command_wait} + type_slow "$SESSION" '/skills add huggingface-datasets' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {command_wait} + type_slow "$SESSION" '/skills' {typing_delay} + tmux send-keys -t "$SESSION" Enter + sleep {command_wait} + sleep {final_wait} + tmux kill-session -t "$SESSION" 2>/dev/null || true +) & + +tmux attach-session -t "$SESSION" || true +""" + + def _required_assets() -> list[Path]: return [ VENDOR_ASCIINEMA / "README.md", @@ -352,6 +440,8 @@ def _record_script(scenario: TerminalCastScenario) -> str: return _skills_direct_install_record_script(scenario) if scenario.name == "skills-slash-commands": return _skills_slash_commands_record_script(scenario) + if scenario.name == "skills-over-mcp": + return _skills_over_mcp_record_script(scenario) typing_delay = os.environ.get("FAST_AGENT_TUI_DEMO_TYPING_DELAY", "0.055") shell_delay = os.environ.get("FAST_AGENT_TUI_DEMO_SHELL_TYPING_DELAY", "0.045") @@ -396,7 +486,7 @@ def _record_script(scenario: TerminalCastScenario) -> str: tmux kill-session -t "$SESSION" 2>/dev/null || true tmux new-session -d -s "$SESSION" -x {scenario.cols} -y {scenario.rows} \\ - "DEMO_FAST_AGENT_HOME=\\$(mktemp -d) && printf '{{}}\\n' > \\\"\\$DEMO_FAST_AGENT_HOME/fast-agent.yaml\\\" && export FAST_AGENT_HOME=\\\"\\$DEMO_FAST_AGENT_HOME\\\" && DEMO_WORKDIR=\\$(mktemp -d -t fast-agent-demo.XXXXXX) && cd \\\"\\$DEMO_WORKDIR\\\" && git init -q && git config user.email docs@example.invalid && git config user.name 'Docs Demo' && printf '# Demo workspace\\n' > README.md && git add README.md && git commit -qm init && printf '\\nLocal edit\\n' >> README.md && unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 TUI__COMPLETION_MENU_RESERVED_LINES=${{TUI__COMPLETION_MENU_RESERVED_LINES:-4}} bash --noprofile --norc" + "DEMO_FAST_AGENT_HOME=\\$(mktemp -d) && printf '{{}}\\n' > \\\"\\$DEMO_FAST_AGENT_HOME/fast-agent.yaml\\\" && export FAST_AGENT_HOME=\\\"\\$DEMO_FAST_AGENT_HOME\\\" && DEMO_WORKDIR=\\$(mktemp -d -t fast-agent-demo.XXXXXX) && cd \\\"\\$DEMO_WORKDIR\\\" && git init -q && git config user.email docs@example.invalid && git config user.name 'Docs Demo' && printf '# Demo workspace\\n' > README.md && git add README.md && git commit -qm init && printf '\\nLocal edit\\n' >> README.md && unset ENVIRONMENT_DIR FAST_AGENT_RUNTIME_ENVIRONMENT VIRTUAL_ENV NO_COLOR && TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1 FAST_AGENT_KEYRING_NOTICE=0 TUI__COMPLETION_MENU_RESERVED_LINES=${{TUI__COMPLETION_MENU_RESERVED_LINES:-4}} bash --noprofile --norc" tmux set-option -t "$SESSION" status off >/dev/null ( @@ -479,6 +569,8 @@ def _is_terminal_teardown_event(line: str) -> bool: or "[detached" in output or "\x1b[?1049l" in output or "\u001b[?1049l" in output + or "\x1b[H\x1b[2J" in output + or "\u001b[H\u001b[2J" in output ) diff --git a/src/fast_agent/acp/slash/handlers/skills.py b/src/fast_agent/acp/slash/handlers/skills.py index 76def70ed..a76dec73c 100644 --- a/src/fast_agent/acp/slash/handlers/skills.py +++ b/src/fast_agent/acp/slash/handlers/skills.py @@ -18,29 +18,17 @@ from fast_agent.commands.command_discovery import render_direct_command_help from fast_agent.commands.handlers import skills as skills_handlers from fast_agent.commands.renderers.skills_markdown import ( - render_marketplace_skills, render_skill_list, render_skills_by_directory, - render_skills_registry_overview, render_skills_remove_list, ) from fast_agent.config import get_settings from fast_agent.core.instruction_refresh import rebuild_agent_instruction from fast_agent.skills import SKILLS_DEFAULT from fast_agent.skills.command_support import ( - filter_marketplace_skills, - marketplace_repository_hint, skills_usage_lines, ) -from fast_agent.skills.configuration import ( - format_marketplace_display_url, - get_marketplace_url, - resolve_skill_registries, -) from fast_agent.skills.operations import ( - candidate_marketplace_urls, - fetch_marketplace_skills, - fetch_marketplace_skills_with_source, reload_skill_manifests, ) from fast_agent.skills.registry import SkillRegistry, format_skills_for_prompt @@ -68,50 +56,14 @@ async def handle_skills_available( *, query: str | None = None, ) -> str: - heading = "skills available" if not query else "skills search" - marketplace_url = get_marketplace_url(get_settings()) - display_url = format_marketplace_display_url(marketplace_url) - try: - marketplace = await fetch_marketplace_skills(marketplace_url) - except Exception as exc: # noqa: BLE001 - return ( - f"# {heading}\n\n" - f"Failed to load marketplace: {exc}\n\n" - f"Repository: `{display_url}`" - ) - - if not marketplace: - return f"# {heading}\n\nNo skills found in the marketplace." - - selected_marketplace = list(marketplace) - if query and query.strip(): - selected_marketplace = filter_marketplace_skills(marketplace, query) - if not selected_marketplace: - return ( - "# skills search\n\n" - f"No skills matched query `{query.strip()}`.\n\n" - "Try `/skills available` to browse all skills." - ) - - repository = display_url - repo_hint = marketplace_repository_hint(marketplace) - if repo_hint: - repository = repo_hint - - rendered = render_marketplace_skills( - selected_marketplace, - heading=heading, - repository=repository, + ctx = handler._build_command_context() + io = cast("ACPCommandIO", ctx.io) + outcome = await skills_handlers.handle_list_marketplace_skills( + ctx, + agent_name=handler.current_agent_name, + query=query, ) - if query and query.strip(): - rendered = "\n".join( - [ - rendered, - "", - "Install filtered results with `/skills add `. ", - ] - ) - return rendered + return handler._format_outcome_as_markdown(outcome, "skills available", io=io) async def handle_skills(handler: "SlashCommandHandler", arguments: str | None = None) -> str: @@ -154,87 +106,14 @@ async def handle_skills(handler: "SlashCommandHandler", arguments: str | None = async def handle_skills_registry(handler: "SlashCommandHandler", argument: str) -> str: - heading = "# skills registry" - argument = argument.strip() - - settings = get_settings() - configured_urls = resolve_skill_registries(settings) - - if not argument: - current = get_marketplace_url(settings) - display_current = format_marketplace_display_url(current) - display_registries = [format_marketplace_display_url(url) for url in configured_urls] - return render_skills_registry_overview( - heading="skills registry", - current_registry=display_current, - configured_urls=display_registries, - ) - - if argument.isdigit(): - index = int(argument) - if not configured_urls: - return f"{heading}\n\nNo registries configured." - if 1 <= index <= len(configured_urls): - url = configured_urls[index - 1] - else: - return f"{heading}\n\nInvalid registry number. Use 1-{len(configured_urls)}." - else: - url = argument - - candidates = candidate_marketplace_urls(url) - try: - marketplace, resolved_url = await fetch_marketplace_skills_with_source(url) - except Exception as exc: # noqa: BLE001 - display_url = format_marketplace_display_url(url) - handler._logger.warning( - "Failed to load skills registry", - data={ - "registry": url, - "candidates": candidates, - "error": str(exc), - }, - ) - return "\n".join( - [ - heading, - "", - f"Failed to load registry: {exc}", - f"Registry: {display_url}", - ] - ) - - if not marketplace: - display_url = format_marketplace_display_url(url) - return "\n".join( - [ - heading, - "", - "No skills found in the registry; registry unchanged.", - f"Registry: {display_url}", - ] - ) - - settings.skills.marketplace_url = resolved_url - - display_url = format_marketplace_display_url(resolved_url) - if candidates: - handler._logger.debug( - "Resolved skills registry", - data={ - "input": url, - "resolved": resolved_url, - "candidates": candidates, - }, - ) - response_lines = [ - heading, - "", - f"Registry set to: `{display_url}`", - "", - f"Skills discovered: {len(marketplace)}", - ] - - return "\n".join(response_lines) + ctx = handler._build_command_context() + io = cast("ACPCommandIO", ctx.io) + outcome = await skills_handlers.handle_set_skills_registry( + ctx, + agent_name=handler.current_agent_name, + argument=argument.strip() or None, + ) + return handler._format_outcome_as_markdown(outcome, "skills registry", io=io) def handle_skills_list(handler: "SlashCommandHandler") -> str: @@ -315,28 +194,15 @@ async def handle_skills_add(handler: "SlashCommandHandler", argument: str) -> st ) if not argument_value: - marketplace_url = get_marketplace_url(get_settings()) - display_url = format_marketplace_display_url(marketplace_url) - try: - marketplace = await fetch_marketplace_skills(marketplace_url) - except Exception as exc: # noqa: BLE001 - return ( - "# skills add\n\n" - f"Failed to load marketplace: {exc}\n\n" - f"Repository: `{display_url}`" - ) - - repository = display_url - if marketplace: - repo_url = marketplace[0].repo_url - repo_ref = marketplace[0].repo_ref - repository = f"{repo_url}@{repo_ref}" if repo_ref else repo_url - - return render_marketplace_skills( - marketplace, - heading="skills add", - repository=repository, + ctx = handler._build_command_context() + io = cast("ACPCommandIO", ctx.io) + outcome = await skills_handlers.handle_add_skill( + ctx, + agent_name=handler.current_agent_name, + argument=None, + interactive=False, ) + return handler._format_outcome_as_markdown(outcome, "skills add", io=io) ctx = handler._build_command_context() io = cast("ACPCommandIO", ctx.io) diff --git a/src/fast_agent/commands/command_catalog.py b/src/fast_agent/commands/command_catalog.py index 77da19735..be0b5eb0b 100644 --- a/src/fast_agent/commands/command_catalog.py +++ b/src/fast_agent/commands/command_catalog.py @@ -169,7 +169,7 @@ class CommandSpec: action="registry", aliases=("source",), help="Set the skills registry", - usage="/skills registry []", + usage="/skills registry []", examples=("/skills registry",), arguments=( CommandArgumentSpec( diff --git a/src/fast_agent/commands/handlers/mcp_runtime.py b/src/fast_agent/commands/handlers/mcp_runtime.py index 9ffbe5be7..1bb915127 100644 --- a/src/fast_agent/commands/handlers/mcp_runtime.py +++ b/src/fast_agent/commands/handlers/mcp_runtime.py @@ -254,7 +254,9 @@ async def _resolve_configured_server_alias( return candidate if candidate in configured_names else None -def _format_added_summary(tools_added_count: int, prompts_added_count: int) -> Text: +def _format_added_summary( + tools_added_count: int, prompts_added_count: int, skills_count: int | None = None +) -> Text: tool_word = "tool" if tools_added_count == 1 else "tools" prompt_word = "prompt" if prompts_added_count == 1 else "prompts" @@ -263,7 +265,13 @@ def _format_added_summary(tools_added_count: int, prompts_added_count: int) -> T summary.append(str(tools_added_count), style="bold bright_cyan") summary.append(f" {tool_word} and ", style="dim") summary.append(str(prompts_added_count), style="bold bright_cyan") - summary.append(f" {prompt_word}.", style="dim") + summary.append(f" {prompt_word}", style="dim") + if skills_count is not None: + skill_word = "skill" if skills_count == 1 else "skills" + summary.append("; ", style="dim") + summary.append(str(skills_count), style="bold bright_cyan") + summary.append(f" {skill_word} available from the server", style="dim") + summary.append(".", style="dim") return summary @@ -273,6 +281,7 @@ def _format_refreshed_summary( prompts_refreshed_count: int, tools_added_count: int, prompts_added_count: int, + skills_count: int | None = None, ) -> Text: tool_word = "tool" if tools_refreshed_count == 1 else "tools" prompt_word = "prompt" if prompts_refreshed_count == 1 else "prompts" @@ -285,7 +294,13 @@ def _format_refreshed_summary( summary.append(str(prompts_refreshed_count), style="bold bright_cyan") summary.append(f" {prompt_word} (", style="dim") summary.append(str(new_count), style="bold bright_cyan") - summary.append(" new).", style="dim") + summary.append(" new)", style="dim") + if skills_count is not None: + skill_word = "skill" if skills_count == 1 else "skills" + summary.append("; ", style="dim") + summary.append(str(skills_count), style="bold bright_cyan") + summary.append(f" {skill_word} available from the server", style="dim") + summary.append(".", style="dim") return summary @@ -1335,6 +1350,7 @@ async def emit_oauth_event(event: OAuthEvent) -> None: prompts_added = getattr(result, "prompts_added", []) tools_total = getattr(result, "tools_total", None) prompts_total = getattr(result, "prompts_total", None) + skills_total = getattr(result, "skills_total", None) warnings = getattr(result, "warnings", []) already_attached = bool(getattr(result, "already_attached", False)) @@ -1382,6 +1398,7 @@ async def emit_oauth_event(event: OAuthEvent) -> None: prompts_refreshed_count=prompts_refreshed_count, tools_added_count=tools_added_count, prompts_added_count=prompts_added_count, + skills_count=skills_total if isinstance(skills_total, int) else None, ), right_info="mcp", agent_name=agent_name, @@ -1391,6 +1408,7 @@ async def emit_oauth_event(event: OAuthEvent) -> None: _format_added_summary( tools_added_count=tools_added_count, prompts_added_count=prompts_added_count, + skills_count=skills_total if isinstance(skills_total, int) else None, ), right_info="mcp", agent_name=agent_name, @@ -1498,6 +1516,7 @@ async def handle_mcp_reconnect( prompts_added = getattr(result, "prompts_added", []) tools_total = getattr(result, "tools_total", None) prompts_total = getattr(result, "prompts_total", None) + skills_total = getattr(result, "skills_total", None) warnings = getattr(result, "warnings", []) tools_added_count = len(tools_added) @@ -1522,6 +1541,7 @@ async def handle_mcp_reconnect( prompts_refreshed_count=prompts_refreshed_count, tools_added_count=tools_added_count, prompts_added_count=prompts_added_count, + skills_count=skills_total if isinstance(skills_total, int) else None, ), right_info="mcp", agent_name=agent_name, diff --git a/src/fast_agent/commands/handlers/skills.py b/src/fast_agent/commands/handlers/skills.py index f555c64c2..af5c5f33b 100644 --- a/src/fast_agent/commands/handlers/skills.py +++ b/src/fast_agent/commands/handlers/skills.py @@ -4,7 +4,7 @@ import asyncio from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable from rich.text import Text @@ -24,6 +24,14 @@ get_marketplace_url, resolve_skill_registries, ) +from fast_agent.skills.mcp_registry import ( + McpRegistrySkill, + McpSkillRegistry, + install_mcp_registry_skill, + select_mcp_registry_skill, + update_mcp_registry_skill, +) +from fast_agent.skills.models import SkillUpdateInfo from fast_agent.skills.operations import ( apply_skill_updates, check_skill_updates, @@ -37,9 +45,11 @@ select_skill_updates, ) from fast_agent.skills.provenance import ( + compute_skill_content_fingerprint, format_installed_at_display, format_revision_short, format_skill_provenance_details, + read_installed_skill_source, ) from fast_agent.skills.registry import SkillManifest, SkillRegistry, format_skills_for_prompt from fast_agent.skills.scope import ( @@ -54,12 +64,57 @@ from fast_agent.commands.context import CommandContext from fast_agent.interfaces import AgentProtocol - from fast_agent.skills.models import SkillUpdateInfo + + +MCP_REGISTRY_PREFIX = "mcp://" + + +@runtime_checkable +class _McpSkillRegistryAggregator(Protocol): + async def list_mcp_skill_registries(self) -> list[McpSkillRegistry]: ... + + +@runtime_checkable +class _McpSkillRegistryAgent(Protocol): + @property + def aggregator(self) -> _McpSkillRegistryAggregator: ... _parse_update_argument = parse_update_argument +def _mcp_registry_source(server_name: str) -> str: + return f"{MCP_REGISTRY_PREFIX}{server_name}" + + +def _mcp_registry_server_name(source: str) -> str | None: + if not source.startswith(MCP_REGISTRY_PREFIX): + return None + server_name = source[len(MCP_REGISTRY_PREFIX) :].strip() + return server_name or None + + +async def _list_mcp_skill_registries( + ctx: CommandContext, *, agent_name: str +) -> list[McpSkillRegistry]: + agent = ctx.agent_provider._agent(agent_name) + if not isinstance(agent, _McpSkillRegistryAgent): + return [] + aggregator = agent.aggregator + if not isinstance(aggregator, _McpSkillRegistryAggregator): + return [] + return await aggregator.list_mcp_skill_registries() + + +def _find_mcp_registry( + registries: Sequence[McpSkillRegistry], server_name: str +) -> McpSkillRegistry | None: + for registry in registries: + if registry.server_name == server_name: + return registry + return None + + def _append_manifest_entry(content: Text, manifest: SkillManifest, index: int) -> None: entry = Text() entry.append(f"[{index:2}] ", style="dim cyan") @@ -97,7 +152,7 @@ def _append_registry_entry( is_current: bool, ) -> None: entry = Text() - entry.append(f"[{index:2}] ", style="dim cyan") + entry.append(f"[{index}] ", style="dim cyan") entry.append(display_url, style="bright_blue bold") if is_current: entry.append(" • ", style="dim") @@ -185,6 +240,7 @@ def _format_marketplace_skills(marketplace: Sequence[object]) -> Text: name = getattr(entry, "name", "") description = getattr(entry, "description", "") source_url = getattr(entry, "source_url", None) + digest = getattr(entry, "digest", None) entry_line = Text() entry_line.append(f"[{index:2}] ", style="dim cyan") @@ -198,6 +254,10 @@ def _format_marketplace_skills(marketplace: Sequence[object]) -> Text: content.append(" ", style="dim") content.append(f"source: {source_url}", style="dim green") content.append("\n") + if digest: + content.append(" ", style="dim") + content.append("integrity: SHA256 checked", style="dim green") + content.append("\n") content.append("\n") return content @@ -239,6 +299,7 @@ def _format_update_results(updates: Sequence[SkillUpdateInfo], *, title: str) -> "source_ref_missing": "source ref missing", "source_path_missing": "source path missing", "skipped_dirty": "skipped (local modifications)", + "integrity_error": "integrity error", } status_detail_channels = { "invalid_metadata", @@ -248,6 +309,7 @@ def _format_update_results(updates: Sequence[SkillUpdateInfo], *, title: str) -> "source_ref_missing", "source_path_missing", "skipped_dirty", + "integrity_error", } detail_prefix = " - " @@ -392,11 +454,14 @@ async def handle_list_skills(ctx: CommandContext, *, agent_name: str) -> Command async def handle_set_skills_registry( - ctx: CommandContext, *, argument: str | None + ctx: CommandContext, *, agent_name: str, argument: str | None ) -> CommandOutcome: outcome = CommandOutcome() settings = ctx.resolve_settings() - configured_urls = resolve_skill_registries(settings) + configured_urls = [ + url for url in resolve_skill_registries(settings) if _mcp_registry_server_name(url) is None + ] + mcp_registries = await _list_mcp_skill_registries(ctx, agent_name=agent_name) if not argument: current = get_marketplace_url(settings) @@ -404,7 +469,12 @@ async def handle_set_skills_registry( configured_displays = [ format_marketplace_display_url(reg_url) for reg_url in configured_urls ] - current_in_configured = current_display in configured_displays + mcp_displays = [registry.display_name for registry in mcp_registries] + current_mcp_server = _mcp_registry_server_name(current) + current_in_configured = current_display in configured_displays or ( + current_mcp_server is not None + and _find_mcp_registry(mcp_registries, current_mcp_server) is not None + ) content = Text() if not current_in_configured: current_line = Text() @@ -413,6 +483,8 @@ async def handle_set_skills_registry( current_line.append(current_display, style="bright_blue bold") content.append_text(current_line) content.append("\n\n") + + if configured_displays: content.append_text(Text("Configured registries:", style="dim")) content.append("\n") @@ -423,28 +495,67 @@ async def handle_set_skills_registry( index=index, is_current=display == current_display, ) + if mcp_displays: + if configured_displays: + content.append("\n") + content.append_text(Text("MCP registries:", style="dim")) + content.append("\n") + offset = len(configured_displays) + for index, registry in enumerate(mcp_registries, offset + 1): + _append_registry_entry( + content, + display_url=registry.display_name, + index=index, + is_current=current_mcp_server == registry.server_name, + ) content.append("\n") - content.append_text(Text("Usage: /skills registry ", style="dim")) + content.append_text( + Text("Usage: /skills registry ", style="dim") + ) outcome.add_message(content, right_info="skills") return outcome arg = str(argument).strip() + selected_mcp: McpSkillRegistry | None = None if arg.isdigit(): index = int(arg) - if not configured_urls: + combined_count = len(configured_urls) + len(mcp_registries) + if combined_count == 0: outcome.add_message("No registries configured.", channel="warning") return outcome if 1 <= index <= len(configured_urls): url = configured_urls[index - 1] + elif len(configured_urls) < index <= combined_count: + selected_mcp = mcp_registries[index - len(configured_urls) - 1] + url = _mcp_registry_source(selected_mcp.server_name) else: outcome.add_message( - f"Invalid registry number. Use 1-{len(configured_urls)}.", + f"Invalid registry number. Use 1-{combined_count}.", channel="warning", ) return outcome else: - url = arg + explicit_mcp_server = _mcp_registry_server_name(arg) or arg + selected_mcp = _find_mcp_registry(mcp_registries, explicit_mcp_server) + url = _mcp_registry_source(selected_mcp.server_name) if selected_mcp is not None else arg + + if selected_mcp is not None: + skills_settings = getattr(settings, "skills", None) + if skills_settings is not None: + skills_settings.marketplace_url = url + + content = Text() + content.append_text( + Text( + f"Registry set to: {selected_mcp.display_name}", + style="green", + ) + ) + content.append("\n") + content.append_text(Text(f"Skills discovered: {len(selected_mcp.skills)}", style="dim")) + outcome.add_message(content, right_info="skills") + return outcome try: marketplace, resolved_url = await fetch_marketplace_skills_with_source(url) @@ -491,6 +602,49 @@ async def handle_list_marketplace_skills( outcome = CommandOutcome() marketplace_url = get_marketplace_url(ctx.resolve_settings()) + mcp_server_name = _mcp_registry_server_name(marketplace_url) + if mcp_server_name is not None: + mcp_registries = await _list_mcp_skill_registries(ctx, agent_name=agent_name) + mcp_registry = _find_mcp_registry(mcp_registries, mcp_server_name) + if mcp_registry is None: + outcome.add_message( + f"MCP skill registry is not available: {mcp_server_name}", + channel="error", + ) + return outcome + marketplace: Sequence[McpRegistrySkill] = mcp_registry.skills + if query and query.strip(): + query_lower = query.strip().lower() + marketplace = [ + skill + for skill in marketplace + if query_lower in skill.name.lower() + or query_lower in (skill.description or "").lower() + ] + if not marketplace: + outcome.add_message("No skills found in the MCP registry.", channel="warning") + return outcome + content = Text() + heading = f"MCP skills from {mcp_registry.display_name}:" + if query and query.strip(): + heading = f"MCP skills from {mcp_registry.display_name} (search: {query.strip()}):" + append_heading(content, heading) + content.append_text(_format_marketplace_skills(marketplace)) + outcome.add_message(content, right_info="skills", agent_name=agent_name) + outcome.add_message( + SKILLS_ADD_HINT_SLASH, + channel="info", + right_info="skills", + agent_name=agent_name, + ) + outcome.add_message( + "Search with `/skills search `.", + channel="info", + right_info="skills", + agent_name=agent_name, + ) + return outcome + try: marketplace = await fetch_marketplace_skills(marketplace_url) except Exception as exc: # noqa: BLE001 @@ -562,6 +716,75 @@ async def handle_add_skill( managed_skills_dir = management_scope.managed_directory selection = argument marketplace_url = get_marketplace_url(ctx.resolve_settings()) + mcp_server_name = _mcp_registry_server_name(marketplace_url) + + if mcp_server_name is not None: + agent = ctx.agent_provider._agent(agent_name) + if not isinstance(agent, _McpSkillRegistryAgent): + outcome.add_message("This agent does not expose MCP skill registries.", channel="error") + return outcome + mcp_registries = await agent.aggregator.list_mcp_skill_registries() + mcp_registry = _find_mcp_registry(mcp_registries, mcp_server_name) + if mcp_registry is None: + outcome.add_message( + f"MCP skill registry is not available: {mcp_server_name}", + channel="error", + ) + return outcome + + if not selection: + content = Text() + append_heading(content, f"MCP skills from {mcp_registry.display_name}:") + content.append_text(_format_marketplace_skills(mcp_registry.skills)) + if not interactive: + outcome.add_message(content, right_info="skills", agent_name=agent_name) + outcome.add_message( + SKILLS_ADD_HINT_SLASH, + channel="info", + right_info="skills", + agent_name=agent_name, + ) + outcome.add_message( + "Change registry with `/skills registry`.", + channel="info", + right_info="skills", + agent_name=agent_name, + ) + return outcome + + await ctx.io.emit( + CommandMessage(text=content, right_info="skills", agent_name=agent_name) + ) + selection = await ctx.io.prompt_selection( + "Install skill by number or name (empty to cancel): ", + options=[skill.name for skill in mcp_registry.skills], + allow_cancel=True, + ) + if selection is None: + return outcome + + mcp_skill = select_mcp_registry_skill(mcp_registry.skills, selection) + if mcp_skill is None: + outcome.add_message(f"Skill not found: {selection}", channel="error") + return outcome + + try: + install_path = await install_mcp_registry_skill( + cast("Any", agent.aggregator), + mcp_skill, + destination_root=managed_skills_dir, + ) + except Exception as exc: # noqa: BLE001 + outcome.add_message(f"Failed to install skill: {exc}", channel="error") + return outcome + + outcome.add_message( + _format_install_result(mcp_skill.name, install_path), + right_info="skills", + agent_name=agent_name, + ) + await _refresh_agent_skills(ctx, agent_name) + return outcome if selection: try: @@ -738,6 +961,217 @@ async def handle_remove_skill( return outcome +async def _enrich_mcp_update_infos( + ctx: CommandContext, + *, + agent_name: str, + updates: list[SkillUpdateInfo], +) -> list[SkillUpdateInfo]: + mcp_updates = [update for update in updates if _is_mcp_update(update)] + if not mcp_updates: + return updates + + registries = await _list_mcp_skill_registries(ctx, agent_name=agent_name) + enriched: list[SkillUpdateInfo] = [] + for update in updates: + if not _is_mcp_update(update): + enriched.append(update) + continue + skill = _find_mcp_skill_for_update(registries, update) + source = update.managed_source + if source is None: + enriched.append(update) + continue + if skill is None: + enriched.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="source_path_missing", + detail="MCP registry entry not found", + current_revision=source.artifact_digest or source.installed_revision, + managed_source=source, + ) + ) + continue + current_digest = source.artifact_digest or source.installed_revision + status = "up_to_date" if current_digest == skill.digest else "update_available" + detail = "already up to date" if status == "up_to_date" else "skill artifact changed" + enriched.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status=status, + detail=detail, + current_revision=current_digest, + available_revision=skill.digest, + managed_source=source, + ) + ) + return enriched + + +async def _apply_mcp_skill_updates( + ctx: CommandContext, + *, + agent_name: str, + updates: list[SkillUpdateInfo], + force: bool, +) -> list[SkillUpdateInfo]: + if not updates: + return [] + agent = ctx.agent_provider._agent(agent_name) + if not isinstance(agent, _McpSkillRegistryAgent): + return [ + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="source_unreachable", + detail="This agent does not expose MCP skill registries.", + current_revision=update.current_revision, + available_revision=update.available_revision, + managed_source=update.managed_source, + ) + for update in updates + ] + + registries = await agent.aggregator.list_mcp_skill_registries() + applied: list[SkillUpdateInfo] = [] + for update in updates: + source = update.managed_source + if source is None: + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="invalid_metadata", + detail="missing source metadata", + ) + ) + continue + + skill = _find_mcp_skill_for_update(registries, update) + if skill is None: + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="source_path_missing", + detail="MCP registry entry not found", + current_revision=source.artifact_digest or source.installed_revision, + managed_source=source, + ) + ) + continue + + current_digest = source.artifact_digest or source.installed_revision + if current_digest == skill.digest: + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="up_to_date", + detail="already up to date", + current_revision=current_digest, + available_revision=skill.digest, + managed_source=source, + ) + ) + continue + + is_dirty = compute_skill_content_fingerprint(update.skill_dir) != source.content_fingerprint + if is_dirty and not force: + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="skipped_dirty", + detail="local modifications detected; rerun with --force", + current_revision=current_digest, + available_revision=skill.digest, + managed_source=source, + ) + ) + continue + + try: + await update_mcp_registry_skill( + cast("Any", agent.aggregator), + skill, + skill_dir=update.skill_dir, + ) + except ValueError as exc: + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="integrity_error", + detail=str(exc), + current_revision=current_digest, + available_revision=skill.digest, + managed_source=source, + ) + ) + continue + except Exception as exc: # noqa: BLE001 + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="source_unreachable", + detail=str(exc), + current_revision=current_digest, + available_revision=skill.digest, + managed_source=source, + ) + ) + continue + + refreshed_source, _error = read_installed_skill_source(update.skill_dir) + applied.append( + SkillUpdateInfo( + index=update.index, + name=update.name, + skill_dir=update.skill_dir, + status="updated", + detail="updated with --force (local changes overwritten)" if is_dirty else "updated", + current_revision=current_digest, + available_revision=skill.digest, + managed_source=refreshed_source or source, + ) + ) + return applied + + +def _is_mcp_update(update: SkillUpdateInfo) -> bool: + source = update.managed_source + return source is not None and source.source_origin == "mcp" + + +def _find_mcp_skill_for_update( + registries: Sequence[McpSkillRegistry], update: SkillUpdateInfo +) -> McpRegistrySkill | None: + source = update.managed_source + if source is None: + return None + for registry in registries: + if registry.server_name != source.mcp_server_name: + continue + for skill in registry.skills: + if skill.source_url == source.source_url or skill.name == update.name: + return skill + return None + + async def handle_update_skill( ctx: CommandContext, *, @@ -753,7 +1187,11 @@ async def handle_update_skill( management_scope = resolve_skills_management_scope(ctx.resolve_settings()) managed_skills_dir = management_scope.managed_directory - updates = check_skill_updates(destination_root=managed_skills_dir) + updates = await _enrich_mcp_update_infos( + ctx, + agent_name=agent_name, + updates=check_skill_updates(destination_root=managed_skills_dir), + ) if selector is None: outcome.add_message( @@ -788,7 +1226,17 @@ async def handle_update_skill( ) return outcome - applied = await asyncio.to_thread(apply_skill_updates, selected, force=force) + mcp_selected = [update for update in selected if _is_mcp_update(update)] + regular_selected = [update for update in selected if not _is_mcp_update(update)] + applied = [ + *await asyncio.to_thread(apply_skill_updates, regular_selected, force=force), + *await _apply_mcp_skill_updates( + ctx, + agent_name=agent_name, + updates=mcp_selected, + force=force, + ), + ] outcome.add_message( _format_update_results(applied, title="Skill update results:"), right_info="skills", @@ -835,7 +1283,7 @@ async def handle_skills_command( if normalized in {"add", "install"}: return await handle_add_skill(ctx, agent_name=agent_name, argument=argument) if normalized in {"registry", "source"}: - return await handle_set_skills_registry(ctx, argument=argument) + return await handle_set_skills_registry(ctx, agent_name=agent_name, argument=argument) if normalized in {"remove", "rm", "delete", "uninstall"}: return await handle_remove_skill(ctx, agent_name=agent_name, argument=argument) if normalized in {"update", "refresh", "upgrade"}: diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index cbfa77dd1..31879c05d 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -77,6 +77,11 @@ from fast_agent.mcp.tool_execution_handler import NoOpToolExecutionHandler, ToolExecutionHandler from fast_agent.mcp.tool_permission_handler import NoOpToolPermissionHandler, ToolPermissionHandler from fast_agent.mcp.transport_tracking import TransportSnapshot +from fast_agent.skills.mcp_registry import ( + McpSkillRegistry, + scan_mcp_skill_registry, + server_supports_mcp_skills, +) from fast_agent.ui.tool_call_ids import format_tool_call_id from fast_agent.utils.async_utils import gather_with_cancel @@ -217,6 +222,7 @@ class ServerStatus(BaseModel): session_title: str | None = None transport_channels: TransportSnapshot | None = None skybridge: SkybridgeServerConfig | None = None + mcp_skills_enabled: bool | None = None reconnect_count: int = 0 ping_interval_seconds: int | None = None ping_max_missed: int | None = None @@ -254,6 +260,7 @@ class MCPAttachResult: warnings: list[str] tools_total: int | None = None prompts_total: int | None = None + skills_total: int | None = None @dataclass(frozen=True, slots=True) @@ -759,6 +766,7 @@ async def attach_server( warnings=[], tools_total=len(existing_tool_names), prompts_total=len(existing_prompt_names), + skills_total=None, ) if attach_options.force_reconnect: @@ -803,7 +811,6 @@ async def attach_server( tools = await self._fetch_server_tools(server_name) prompts = await self._fetch_server_prompts(server_name) - async with self._tool_map_lock: for namespaced in self._server_to_tool_map.get(server_name, []): self._namespaced_tool_map.pop(namespaced.namespaced_tool_name, None) @@ -852,6 +859,7 @@ async def attach_server( warnings=list(skybridge_config.warnings), tools_total=len(tool_names), prompts_total=len(prompt_names), + skills_total=None, ) async def detach_server(self, server_name: str) -> MCPDetachResult: @@ -1128,6 +1136,27 @@ def _display_startup_state(self, total_tool_count: int, total_prompt_count: int) }, ) + def _server_implementation_version(self, server_name: str) -> str | None: + manager = self._persistent_connection_manager + if not self.connection_persistence or manager is None: + return None + server_conn = manager.running_servers.get(server_name) + if server_conn is None or server_conn.server_implementation is None: + return None + return server_conn.server_implementation.version + + async def list_mcp_skill_registries(self) -> list[McpSkillRegistry]: + registries: list[McpSkillRegistry] = [] + for server_name in self.list_attached_servers(): + registry = await scan_mcp_skill_registry( + self, + server_name, + server_version=self._server_implementation_version(server_name), + ) + if registry is not None: + registries.append(registry) + return registries + async def get_capabilities(self, server_name: str) -> ServerCapabilities | None: """Get server capabilities if available.""" if not self.connection_persistence: @@ -1434,6 +1463,7 @@ async def collect_server_status(self) -> dict[str, ServerStatus]: ping_activity_buckets: list[str] | None = None ping_activity_bucket_seconds: int | None = None ping_activity_bucket_count: int | None = None + mcp_skills_enabled: bool | None = None manager = self._persistent_connection_manager if self.connection_persistence and manager is not None: @@ -1448,6 +1478,7 @@ async def collect_server_status(self) -> dict[str, ServerStatus]: implementation_name = implementation.name implementation_version = implementation.version capabilities = server_conn.server_capabilities + mcp_skills_enabled = server_supports_mcp_skills(capabilities) client_capabilities = server_conn.client_capabilities session = server_conn.session if isinstance(session, SessionClientInfoCapable): @@ -1620,6 +1651,7 @@ async def collect_server_status(self) -> dict[str, ServerStatus]: session_title=session_title, transport_channels=transport_snapshot, skybridge=self._skybridge_configs.get(server_name), + mcp_skills_enabled=mcp_skills_enabled, reconnect_count=reconnect_count, ping_interval_seconds=ping_interval_seconds, ping_max_missed=ping_max_missed, diff --git a/src/fast_agent/mcp/tool_result_metadata.py b/src/fast_agent/mcp/tool_result_metadata.py new file mode 100644 index 000000000..cfc4f8cf7 --- /dev/null +++ b/src/fast_agent/mcp/tool_result_metadata.py @@ -0,0 +1,29 @@ +"""Display-only metadata helpers for MCP tool results.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp.types import CallToolResult, ContentBlock + +_MEDIA_PREVIEW_ATTR = "_fast_agent_media_preview_content" + + +def set_tool_result_media_preview( + result: CallToolResult, + content: Sequence[ContentBlock], +) -> None: + """Attach media preview blocks without changing provider-facing result content.""" + setattr(result, _MEDIA_PREVIEW_ATTR, list(content)) + + +def get_tool_result_media_preview( + result: CallToolResult, +) -> Sequence[ContentBlock] | None: + """Return display-only media preview blocks attached to a tool result.""" + value = getattr(result, _MEDIA_PREVIEW_ATTR, None) + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return None + return value diff --git a/src/fast_agent/skills/command_support.py b/src/fast_agent/skills/command_support.py index 35b8ebc7a..6fcc30ff2 100644 --- a/src/fast_agent/skills/command_support.py +++ b/src/fast_agent/skills/command_support.py @@ -28,6 +28,7 @@ def skills_usage_lines() -> list[str]: "- /skills add https://github.com/org/repo/blob/main/skills/example/SKILL.md", "- /skills add ./skills/example", "- /skills registry", + "- /skills registry ", ] diff --git a/src/fast_agent/skills/mcp_registry.py b/src/fast_agent/skills/mcp_registry.py new file mode 100644 index 000000000..6c0337a8d --- /dev/null +++ b/src/fast_agent/skills/mcp_registry.py @@ -0,0 +1,428 @@ +"""SEP-2640 Skills over MCP registry support. + +This module intentionally implements the registry/install/update portion of the +draft SEP: servers that advertise ``io.modelcontextprotocol/skills`` can be +used as a source for installing SHA256-verified ``skill-md`` or archive entries +into the normal managed skills directory. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import io +import json +import re +import shutil +import stat +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any, Iterable, Literal, Mapping, Protocol +from urllib.parse import urlparse + +from mcp.types import BlobResourceContents, ServerCapabilities, TextResourceContents + +from fast_agent.core.logging.logger import get_logger +from fast_agent.marketplace import source_utils as marketplace_source_utils +from fast_agent.skills.provenance import ( + build_mcp_installed_skill_source, + compute_skill_content_fingerprint, + write_installed_skill_source, +) +from fast_agent.skills.registry import SkillRegistry + +if TYPE_CHECKING: + from mcp.types import ReadResourceResult + + +class McpSkillRegistryClient(Protocol): + async def get_capabilities(self, server_name: str) -> ServerCapabilities | None: ... + + async def get_resource( + self, resource_uri: str, *, server_name: str | None = None + ) -> "ReadResourceResult": ... + + +class McpSkillRegistryStatusClient(McpSkillRegistryClient, Protocol): + async def collect_server_status(self) -> Mapping[str, Any]: ... + + +logger = get_logger(__name__) + +SKILLS_EXTENSION = "io.modelcontextprotocol/skills" +INDEX_URI = "skill://index.json" +MAX_INDEX_BYTES = 1_048_576 +MAX_SKILL_MD_BYTES = 262_144 +MAX_ARCHIVE_BYTES = 10 * 1_048_576 +MAX_UNPACKED_ARCHIVE_BYTES = 50 * 1_048_576 +ArtifactType = Literal["skill-md", "archive"] +SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class McpRegistrySkill: + name: str + description: str | None + source_url: str + server_name: str + digest: str + artifact_type: ArtifactType = "skill-md" + server_version: str | None = None + + @property + def install_dir_name(self) -> str: + return self.name + + +@dataclass(frozen=True) +class McpSkillRegistry: + server_name: str + server_version: str | None + skills: list[McpRegistrySkill] + + @property + def display_name(self) -> str: + version = f"@{self.server_version}" if self.server_version else "" + return f"mcp-server {self.server_name}{version}" + + +def server_supports_mcp_skills(capabilities: ServerCapabilities | None) -> bool: + if capabilities is None: + return False + extras = capabilities.model_extra or {} + extensions = extras.get("extensions") + if not isinstance(extensions, Mapping): + return False + return SKILLS_EXTENSION in extensions + + +async def scan_mcp_skill_registry( + aggregator: McpSkillRegistryClient, + server_name: str, + *, + server_version: str | None = None, +) -> McpSkillRegistry | None: + capabilities = await aggregator.get_capabilities(server_name) + if not server_supports_mcp_skills(capabilities): + return None + + try: + result = await aggregator.get_resource(INDEX_URI, server_name=server_name) + except Exception as exc: # noqa: BLE001 - optional index; registry simply has no entries. + logger.debug( + "SEP-2640 skills index unavailable", + data={"server": server_name, "error": str(exc)}, + ) + return McpSkillRegistry(server_name=server_name, server_version=server_version, skills=[]) + + entries = _parse_index(result, server_name) + skills: list[McpRegistrySkill] = [] + for entry in entries: + entry_type = entry.get("type") + if entry_type not in {"skill-md", "archive"}: + logger.warning( + "Skipping unsupported MCP skill entry type", + data={"server": server_name, "type": entry_type}, + ) + continue + name = entry.get("name") + url = entry.get("url") + digest = entry.get("digest") + description = entry.get("description") + if not isinstance(name, str) or not name.strip(): + logger.warning("MCP skill entry missing name", data={"server": server_name}) + continue + if not isinstance(url, str) or not url.strip(): + logger.warning("MCP skill entry missing url", data={"server": server_name}) + continue + if url.lower().startswith("file://"): + logger.warning( + "Rejecting file:// MCP skill URL", + data={"server": server_name, "url": url}, + ) + continue + if not isinstance(digest, str) or not _is_valid_sha256_digest(digest): + logger.warning( + "MCP skill entry missing valid sha256 digest", + data={"server": server_name, "name": name}, + ) + continue + artifact_type: ArtifactType = "archive" if entry_type == "archive" else "skill-md" + skills.append( + McpRegistrySkill( + name=name.strip(), + description=description.strip() if isinstance(description, str) else None, + source_url=_resolve_entry_url(url.strip()), + server_name=server_name, + digest=digest.strip(), + artifact_type=artifact_type, + server_version=server_version, + ) + ) + return McpSkillRegistry(server_name=server_name, server_version=server_version, skills=skills) + + +async def list_mcp_skill_registries( + aggregator: McpSkillRegistryStatusClient, server_names: Iterable[str] +) -> list[McpSkillRegistry]: + registries: list[McpSkillRegistry] = [] + for server_name in server_names: + server_version = await _server_version(aggregator, server_name) + registry = await scan_mcp_skill_registry( + aggregator, server_name, server_version=server_version + ) + if registry is not None: + registries.append(registry) + return registries + + +async def install_mcp_registry_skill( + aggregator: McpSkillRegistryClient, + skill: McpRegistrySkill, + *, + destination_root: Path, +) -> Path: + install_dir = destination_root.resolve() / _safe_install_dir_name(skill.name) + if install_dir.exists(): + raise FileExistsError(f"Skill already exists: {install_dir}") + + try: + await _write_verified_mcp_skill(aggregator, skill, install_dir) + except Exception: + if install_dir.exists(): + shutil.rmtree(install_dir) + raise + return install_dir + + +async def update_mcp_registry_skill( + aggregator: McpSkillRegistryClient, + skill: McpRegistrySkill, + *, + skill_dir: Path, +) -> Path: + skill_dir = skill_dir.resolve() + parent_dir = skill_dir.parent + with tempfile.TemporaryDirectory( + dir=parent_dir, + prefix=f".{skill_dir.name}.update-", + ) as temp_dir_str: + staged_dir = Path(temp_dir_str) / skill_dir.name + await _write_verified_mcp_skill(aggregator, skill, staged_dir) + marketplace_source_utils.atomic_replace_directory( + existing_dir=skill_dir, + staged_dir=staged_dir, + ) + return skill_dir + + +def select_mcp_registry_skill( + entries: Iterable[McpRegistrySkill], + selector: str, +) -> McpRegistrySkill | None: + selector_clean = selector.strip() + if not selector_clean: + return None + entries_list = list(entries) + if selector_clean.isdigit(): + index = int(selector_clean) + if 1 <= index <= len(entries_list): + return entries_list[index - 1] + return None + selector_lower = selector_clean.lower() + for entry in entries_list: + if entry.name.lower() == selector_lower: + return entry + return None + + +async def _server_version( + aggregator: McpSkillRegistryStatusClient, server_name: str +) -> str | None: + status_map = await aggregator.collect_server_status() + status = status_map.get(server_name) + if status is None: + return None + return status.implementation_version + + +def _parse_index(result: "ReadResourceResult", server_name: str) -> list[dict[str, Any]]: + text = _first_text(result) + if not text: + return [] + if len(text.encode("utf-8")) > MAX_INDEX_BYTES: + logger.warning( + "MCP skill index exceeds size limit", + data={"server": server_name, "limit": MAX_INDEX_BYTES}, + ) + return [] + try: + parsed = json.loads(text) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Failed to parse MCP skill index", + data={"server": server_name, "error": str(exc)}, + ) + return [] + skills = parsed.get("skills") if isinstance(parsed, dict) else None + if not isinstance(skills, list): + return [] + return [entry for entry in skills if isinstance(entry, dict)] + + +def _first_text(result: "ReadResourceResult") -> str | None: + for item in result.contents: + if isinstance(item, TextResourceContents): + return item.text + return None + + +def _first_bytes(result: "ReadResourceResult") -> bytes | None: + for item in result.contents: + if isinstance(item, TextResourceContents): + return item.text.encode("utf-8") + if isinstance(item, BlobResourceContents): + return base64.b64decode(item.blob) + return None + + +async def _write_verified_mcp_skill( + aggregator: McpSkillRegistryClient, + skill: McpRegistrySkill, + install_dir: Path, +) -> None: + result = await aggregator.get_resource(skill.source_url, server_name=skill.server_name) + artifact = _first_bytes(result) + if artifact is None: + raise ValueError(f"MCP skill resource returned no content: {skill.source_url}") + _verify_artifact_digest(artifact, skill.digest) + + if skill.artifact_type == "skill-md": + _write_skill_md_artifact(skill, artifact, install_dir) + else: + _write_archive_artifact(skill, artifact, install_dir) + + fingerprint = compute_skill_content_fingerprint(install_dir) + write_installed_skill_source( + install_dir, + build_mcp_installed_skill_source( + server_name=skill.server_name, + server_version=skill.server_version, + skill_uri=skill.source_url, + fingerprint=fingerprint, + artifact_digest=skill.digest, + artifact_type=skill.artifact_type, + ), + ) + + +def _write_skill_md_artifact(skill: McpRegistrySkill, artifact: bytes, install_dir: Path) -> None: + if len(artifact) > MAX_SKILL_MD_BYTES: + raise ValueError(f"MCP skill SKILL.md exceeds size limit: {skill.source_url}") + skill_text = artifact.decode("utf-8") + manifest, parse_error = SkillRegistry.parse_manifest_text(skill_text) + if manifest is None: + raise ValueError(f"Failed to parse MCP skill manifest: {parse_error}") + if manifest.name != skill.name: + raise ValueError( + f"MCP skill index name '{skill.name}' does not match manifest name '{manifest.name}'" + ) + + install_dir.mkdir(parents=True, exist_ok=False) + (install_dir / "SKILL.md").write_text(skill_text, encoding="utf-8") + + +def _write_archive_artifact(skill: McpRegistrySkill, artifact: bytes, install_dir: Path) -> None: + if len(artifact) > MAX_ARCHIVE_BYTES: + raise ValueError(f"MCP skill archive exceeds size limit: {skill.source_url}") + install_dir.mkdir(parents=True, exist_ok=False) + try: + if skill.source_url.lower().endswith(".zip"): + _extract_zip_safely(artifact, install_dir) + else: + _extract_tar_safely(artifact, install_dir) + manifest_path = install_dir / "SKILL.md" + if not manifest_path.is_file(): + raise ValueError("MCP skill archive must contain SKILL.md at the root") + manifest, parse_error = SkillRegistry.parse_manifest_text( + manifest_path.read_text(encoding="utf-8") + ) + if manifest is None: + raise ValueError(f"Failed to parse MCP skill manifest: {parse_error}") + if manifest.name != skill.name: + raise ValueError( + f"MCP skill index name '{skill.name}' does not match manifest name '{manifest.name}'" + ) + except Exception: + if install_dir.exists(): + shutil.rmtree(install_dir) + raise + + +def _extract_tar_safely(artifact: bytes, destination: Path) -> None: + total_size = 0 + with tarfile.open(fileobj=io.BytesIO(artifact), mode="r:*") as archive: + for member in archive.getmembers(): + _validate_archive_name(member.name) + if member.issym() or member.islnk(): + raise ValueError("MCP skill archives must not contain links") + if member.isfile(): + total_size += member.size + if total_size > MAX_UNPACKED_ARCHIVE_BYTES: + raise ValueError("MCP skill archive unpacked size exceeds limit") + archive.extractall(destination, filter="data") + + +def _extract_zip_safely(artifact: bytes, destination: Path) -> None: + total_size = 0 + with zipfile.ZipFile(io.BytesIO(artifact)) as archive: + for info in archive.infolist(): + _validate_archive_name(info.filename) + mode = info.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ValueError("MCP skill archives must not contain links") + total_size += info.file_size + if total_size > MAX_UNPACKED_ARCHIVE_BYTES: + raise ValueError("MCP skill archive unpacked size exceeds limit") + archive.extractall(destination) + + +def _validate_archive_name(name: str) -> None: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"Unsafe path in MCP skill archive: {name}") + + +def _verify_artifact_digest(artifact: bytes, expected: str) -> None: + if not _is_valid_sha256_digest(expected): + raise ValueError("MCP skill entry is missing a valid SHA256 digest") + actual = f"sha256:{hashlib.sha256(artifact).hexdigest()}" + if actual != expected: + raise ValueError(f"MCP skill SHA256 mismatch: expected {expected}, got {actual}") + + +def _is_valid_sha256_digest(value: str) -> bool: + return bool(SHA256_RE.fullmatch(value)) + + +def _resolve_entry_url(url: str) -> str: + parsed = urlparse(url) + if parsed.scheme: + return url + return f"skill://{url.lstrip('/')}" + + +def _safe_install_dir_name(name: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name): + raise ValueError(f"Invalid MCP skill name for local install: {name}") + return name + + +def scan_mcp_skill_registry_sync( + aggregator: McpSkillRegistryClient, server_name: str +) -> McpSkillRegistry | None: + return asyncio.run(scan_mcp_skill_registry(aggregator, server_name)) diff --git a/src/fast_agent/skills/models.py b/src/fast_agent/skills/models.py index 8e9126c13..4938fa421 100644 --- a/src/fast_agent/skills/models.py +++ b/src/fast_agent/skills/models.py @@ -24,7 +24,7 @@ SKILL_SOURCE_SCHEMA_VERSION = 1 LOCAL_REVISION = "local" -SkillSourceOrigin = Literal["remote", "local"] +SkillSourceOrigin = Literal["remote", "local", "mcp"] SkillUpdateStatus = Literal[ "up_to_date", "update_available", @@ -37,6 +37,7 @@ "source_ref_missing", "source_path_missing", "skipped_dirty", + "integrity_error", ] SkillManagementSource = Literal["override", "settings", "default"] @@ -55,6 +56,10 @@ class InstalledSkillSource: installed_revision: str installed_at: str content_fingerprint: str + mcp_server_name: str | None = None + mcp_server_version: str | None = None + artifact_digest: str | None = None + artifact_type: str | None = None @dataclass(frozen=True) diff --git a/src/fast_agent/skills/operations.py b/src/fast_agent/skills/operations.py index 3bbb17cef..f68ba3a30 100644 --- a/src/fast_agent/skills/operations.py +++ b/src/fast_agent/skills/operations.py @@ -247,6 +247,8 @@ def apply_skill_updates( "source_unreachable", "source_ref_missing", "source_path_missing", + "unknown_revision", + "integrity_error", }: results.append(refreshed) continue @@ -427,6 +429,18 @@ def _evaluate_skill_update( managed_source=source, ) + if source.source_origin == "mcp": + return SkillUpdateInfo( + index=index, + name=name, + skill_dir=skill_dir, + status="unknown_revision", + detail="source is an MCP skill registry; compare unavailable", + current_revision=source.installed_revision, + available_revision=source.installed_revision, + managed_source=source, + ) + if source.installed_commit is None and source.installed_revision == LOCAL_REVISION: return SkillUpdateInfo( index=index, diff --git a/src/fast_agent/skills/provenance.py b/src/fast_agent/skills/provenance.py index 36f9c41f7..a6b4b5e5d 100644 --- a/src/fast_agent/skills/provenance.py +++ b/src/fast_agent/skills/provenance.py @@ -82,6 +82,14 @@ def write_installed_skill_source(skill_dir: Path, source: InstalledSkillSource) "installed_at": source.installed_at, "content_fingerprint": source.content_fingerprint, } + if source.mcp_server_name is not None: + payload["mcp_server_name"] = source.mcp_server_name + if source.mcp_server_version is not None: + payload["mcp_server_version"] = source.mcp_server_version + if source.artifact_digest is not None: + payload["artifact_digest"] = source.artifact_digest + if source.artifact_type is not None: + payload["artifact_type"] = source.artifact_type sidecar_path.write_text( json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8", @@ -103,7 +111,14 @@ def get_skill_provenance(skill_dir: Path) -> SkillProvenance: ) ref_label = f"@{source.repo_ref}" if source.repo_ref else "" - if source.source_origin == "remote": + if source.source_origin == "mcp": + version = f"@{source.mcp_server_version}" if source.mcp_server_version else "" + summary = ( + "managed (mcp)" + f" • {source.mcp_server_name or source.repo_url}{version}" + f" • {source.repo_path}" + ) + elif source.source_origin == "remote": summary = ( "managed (marketplace)" f" • {source.repo_url}{ref_label}" @@ -138,6 +153,19 @@ def format_skill_provenance_details(skill_dir: Path) -> tuple[str, str | None]: return provenance.summary, None source = provenance.source + if source.source_origin == "mcp": + version = f"@{source.mcp_server_version}" if source.mcp_server_version else "" + integrity = " • integrity: sha256" if source.artifact_digest else "" + provenance_value = ( + f"mcp-server {source.mcp_server_name or source.repo_url}{version} " + f"({source.repo_path}){integrity}" + ) + installed_value = ( + f"{format_installed_at_display(source.installed_at)} " + f"revision: {format_revision_short(source.installed_revision)}" + ) + return provenance_value, installed_value + ref_label = f"@{source.repo_ref}" if source.repo_ref else "" provenance_value = f"{source.repo_url}{ref_label} ({source.repo_path})" installed_value = ( @@ -148,6 +176,10 @@ def format_skill_provenance_details(skill_dir: Path) -> tuple[str, str | None]: def parse_installed_skill_source_payload(payload: dict[str, Any]) -> InstalledSkillSource: + installed_via = payload.get("installed_via") + if installed_via == "mcp": + return _parse_mcp_installed_skill_source_payload(payload) + parsed = marketplace_source_utils.parse_installed_source_fields( payload, expected_schema_version=SKILL_SOURCE_SCHEMA_VERSION, @@ -170,6 +202,75 @@ def parse_installed_skill_source_payload(payload: dict[str, Any]) -> InstalledSk ) +def _parse_mcp_installed_skill_source_payload(payload: dict[str, Any]) -> InstalledSkillSource: + schema_version = payload.get("schema_version") + if schema_version != SKILL_SOURCE_SCHEMA_VERSION: + raise ValueError(f"unsupported schema_version: {schema_version}") + + source_origin = payload.get("source_origin") + if source_origin != "mcp": + raise ValueError("source_origin must be 'mcp'") + + repo_url = payload.get("repo_url") + if not isinstance(repo_url, str) or not repo_url.strip(): + raise ValueError("repo_url is required") + + repo_path = payload.get("repo_path") + if not isinstance(repo_path, str) or not repo_path.strip(): + raise ValueError("repo_path is required") + + source_url = payload.get("source_url") + if source_url is not None and not isinstance(source_url, str): + raise ValueError("source_url must be a string or null") + + installed_revision = payload.get("installed_revision") + if not isinstance(installed_revision, str) or not installed_revision.strip(): + raise ValueError("installed_revision is required") + + installed_at = payload.get("installed_at") + if not isinstance(installed_at, str) or not installed_at.strip(): + raise ValueError("installed_at is required") + + content_fingerprint = payload.get("content_fingerprint") + if not isinstance(content_fingerprint, str) or not content_fingerprint.startswith("sha256:"): + raise ValueError("content_fingerprint must be a sha256 fingerprint") + + artifact_digest = payload.get("artifact_digest") + if artifact_digest is not None and not isinstance(artifact_digest, str): + raise ValueError("artifact_digest must be a string or null") + + artifact_type = payload.get("artifact_type") + if artifact_type is not None and not isinstance(artifact_type, str): + raise ValueError("artifact_type must be a string or null") + + server_name = payload.get("mcp_server_name") + if server_name is not None and not isinstance(server_name, str): + raise ValueError("mcp_server_name must be a string or null") + + server_version = payload.get("mcp_server_version") + if server_version is not None and not isinstance(server_version, str): + raise ValueError("mcp_server_version must be a string or null") + + return InstalledSkillSource( + schema_version=SKILL_SOURCE_SCHEMA_VERSION, + installed_via="mcp", + source_origin="mcp", + repo_url=repo_url.strip(), + repo_ref=None, + repo_path=repo_path.strip(), + source_url=source_url.strip() if isinstance(source_url, str) else None, + installed_commit=None, + installed_path_oid=None, + installed_revision=installed_revision.strip(), + installed_at=installed_at.strip(), + content_fingerprint=content_fingerprint, + mcp_server_name=server_name.strip() if isinstance(server_name, str) else None, + mcp_server_version=server_version.strip() if isinstance(server_version, str) else None, + artifact_digest=artifact_digest.strip() if isinstance(artifact_digest, str) else None, + artifact_type=artifact_type.strip() if isinstance(artifact_type, str) else None, + ) + + def build_installed_skill_source( *, skill: MarketplaceSkill, @@ -195,5 +296,34 @@ def build_installed_skill_source( ) +def build_mcp_installed_skill_source( + *, + server_name: str, + server_version: str | None, + skill_uri: str, + fingerprint: str, + artifact_digest: str, + artifact_type: str, +) -> InstalledSkillSource: + return InstalledSkillSource( + schema_version=SKILL_SOURCE_SCHEMA_VERSION, + installed_via="mcp", + source_origin="mcp", + repo_url=f"mcp://{server_name}", + repo_ref=None, + repo_path=skill_uri, + source_url=skill_uri, + installed_commit=None, + installed_path_oid=None, + installed_revision=artifact_digest, + installed_at=_iso_utc_now(), + content_fingerprint=fingerprint, + mcp_server_name=server_name, + mcp_server_version=server_version, + artifact_digest=artifact_digest, + artifact_type=artifact_type, + ) + + def _iso_utc_now() -> str: return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/src/fast_agent/tools/local_filesystem_runtime.py b/src/fast_agent/tools/local_filesystem_runtime.py index 7092a3293..b89802023 100644 --- a/src/fast_agent/tools/local_filesystem_runtime.py +++ b/src/fast_agent/tools/local_filesystem_runtime.py @@ -14,6 +14,8 @@ from mcp.types import CallToolResult, ContentBlock, TextContent, Tool +from fast_agent.mcp.mime_utils import is_image_mime_type +from fast_agent.mcp.tool_result_metadata import set_tool_result_media_preview from fast_agent.patch.engine import apply_patch as run_apply_patch from fast_agent.patch.errors import ApplyPatchError from fast_agent.tools.apply_patch_tool import ( @@ -517,7 +519,7 @@ async def attach_media( mode = "linked" if attached.linked else "embedded" self._pending_media_attachments.append(attached.block) - return CallToolResult( + result = CallToolResult( content=[ TextContent( type="text", @@ -529,6 +531,9 @@ async def attach_media( ], isError=False, ) + if is_image_mime_type(attached.mime_type): + set_tool_result_media_preview(result, [attached.block]) + return result async def attach_resource( self, arguments: dict[str, Any] | None = None, tool_use_id: str | None = None diff --git a/src/fast_agent/ui/mcp_display.py b/src/fast_agent/ui/mcp_display.py index 2dab006a8..64f456de3 100644 --- a/src/fast_agent/ui/mcp_display.py +++ b/src/fast_agent/ui/mcp_display.py @@ -393,6 +393,7 @@ def _format_capability_shorthand( ("Co", bool(completion_caps), False), ("Ex", bool(experimental_caps), False), ("In", _instruction_capability_state(status, template_expected=template_expected), False), + ("Sk", bool(status.mcp_skills_enabled), False), ("Ui", _skybridge_capability_state(status), False), ("Ro", bool(status.roots_configured), False), ("El", _elicitation_capability_state(status.elicitation_mode), False), @@ -1193,6 +1194,18 @@ def _render_server_calls(status: ServerStatus, *, indent: str) -> None: console.console.print(reconnect_line) +def _render_mcp_skills_hint(status: ServerStatus, *, indent: str) -> None: + if not status.mcp_skills_enabled: + return + + skills_line = Text(indent + " ") + skills_line.append( + "SEP-2640 Skills over MCP is enabled: use `/skills registry` to select them", + style=Colours.TEXT_SUCCESS, + ) + console.console.print(skills_line) + + def _render_capability_banner( tokens: list[tuple[str, str]], *, @@ -1236,6 +1249,7 @@ def _render_server_status_block( _render_server_state(status, indent=indent, template_expected=template_expected) _render_server_calls(status, indent=indent) _render_channel_summary(status, indent, total_width) + _render_mcp_skills_hint(status, indent=indent) _render_capability_banner( primary_caps + secondary_caps, indent=indent, diff --git a/src/fast_agent/ui/prompt/agent_info.py b/src/fast_agent/ui/prompt/agent_info.py index 8554f547e..9cabbfc80 100644 --- a/src/fast_agent/ui/prompt/agent_info.py +++ b/src/fast_agent/ui/prompt/agent_info.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, cast from rich import print as rich_print @@ -17,6 +18,13 @@ from fast_agent.core.agent_app import AgentApp +@dataclass(frozen=True, slots=True) +class AgentSurfaceCounts: + server_count: int = 0 + tool_count: int = 0 + prompt_count: int = 0 + resource_count: int = 0 + async def display_agent_info( agent_name: str, @@ -74,21 +82,21 @@ async def _build_standard_agent_info_parts(agent: AgentProtocol) -> list[str]: content_parts.append(f"{child_count:,}[dim] {child_word}[/dim]") server_count = _server_count_for_agent(agent) - tool_count, prompt_count, resource_count = await _resource_counts_for_agent(agent) + counts = await _resource_counts_for_agent(agent) if server_count > 0: content_parts.append( _format_server_summary( server_count=server_count, - tool_count=tool_count, - prompt_count=prompt_count, - resource_count=resource_count, + tool_count=counts.tool_count, + prompt_count=counts.prompt_count, + resource_count=counts.resource_count, ) ) skill_count = _skill_count_for_agent(agent) if skill_count > 0: skill_word = "skill" if skill_count == 1 else "skills" - content_parts.append(f"{skill_count:,}[dim] {skill_word}[/dim][dim] available[/dim]") + content_parts.append(f"{skill_count:,}[dim] {skill_word} installed[/dim]") return content_parts @@ -100,7 +108,10 @@ def _server_count_for_agent(agent: AgentProtocol) -> int: return len(server_names) if server_names else 0 -async def _resource_counts_for_agent(agent: AgentProtocol) -> tuple[int, int, int]: +async def _resource_counts_for_agent(agent: AgentProtocol) -> AgentSurfaceCounts: + if isinstance(agent, McpAgentProtocol): + return await _mcp_server_resource_counts(agent) + tools_result = await agent.list_tools() tool_count = len(tools_result.tools) @@ -109,7 +120,28 @@ async def _resource_counts_for_agent(agent: AgentProtocol) -> tuple[int, int, in prompts_dict = await agent.list_prompts() prompt_count = sum(len(prompts) for prompts in prompts_dict.values()) if prompts_dict else 0 - return tool_count, prompt_count, resource_count + return AgentSurfaceCounts( + tool_count=tool_count, + prompt_count=prompt_count, + resource_count=resource_count, + ) + + +async def _mcp_server_resource_counts(agent: McpAgentProtocol) -> AgentSurfaceCounts: + """Count MCP server surfaces without local runtime or child-agent tools.""" + tools_result = await agent.aggregator.list_tools() + tool_count = len(tools_result.tools) + + resources_dict = await agent.aggregator.list_resources() + resource_count = sum(len(resources) for resources in resources_dict.values()) if resources_dict else 0 + + prompts_dict = await agent.aggregator.list_prompts() + prompt_count = sum(len(prompts) for prompts in prompts_dict.values()) if prompts_dict else 0 + return AgentSurfaceCounts( + tool_count=tool_count, + prompt_count=prompt_count, + resource_count=resource_count, + ) def _format_server_summary( @@ -266,14 +298,15 @@ async def _display_child_agent_info( ) -> None: del agent_provider try: - server_count, tool_count, prompt_count, resource_count = await _child_agent_counts(child_agent) + counts = await _child_agent_counts(child_agent) + server_count = counts.server_count if server_count > 0: rich_print( f"[dim] {prefix} [/dim][blue]{child_agent.name}[/blue][dim]:[/dim] " f"{server_count:,}[dim] MCP {'Server' if server_count == 1 else 'Servers'}, [/dim]" - f"{tool_count:,}[dim] {'tool' if tool_count == 1 else 'tools'}, [/dim]" - f"{resource_count:,}[dim] {'resource' if resource_count == 1 else 'resources'}, [/dim]" - f"{prompt_count:,}[dim] {'prompt' if prompt_count == 1 else 'prompts'} available[/dim]" + f"{counts.tool_count:,}[dim] {'tool' if counts.tool_count == 1 else 'tools'}, [/dim]" + f"{counts.resource_count:,}[dim] {'resource' if counts.resource_count == 1 else 'resources'}, [/dim]" + f"{counts.prompt_count:,}[dim] {'prompt' if counts.prompt_count == 1 else 'prompts'} available[/dim]" ) return rich_print(f"[dim] {prefix} [/dim][blue]{child_agent.name}[/blue][dim]: No MCP Servers[/dim]") @@ -281,11 +314,18 @@ async def _display_child_agent_info( rich_print(f"[dim] {prefix} [/dim][blue]{child_agent.name}[/blue]") -async def _child_agent_counts(child_agent: AgentProtocol) -> tuple[int, int, int, int]: +async def _child_agent_counts(child_agent: AgentProtocol) -> AgentSurfaceCounts: server_count = 0 if isinstance(child_agent, McpAgentProtocol): server_names = child_agent.aggregator.server_names server_count = len(server_names) if server_names else 0 + counts = await _mcp_server_resource_counts(child_agent) + return AgentSurfaceCounts( + server_count=server_count, + tool_count=counts.tool_count, + prompt_count=counts.prompt_count, + resource_count=counts.resource_count, + ) tools_result = await child_agent.list_tools() tool_count = len(tools_result.tools) @@ -295,4 +335,9 @@ async def _child_agent_counts(child_agent: AgentProtocol) -> tuple[int, int, int prompts_dict = await child_agent.list_prompts() prompt_count = sum(len(prompts) for prompts in prompts_dict.values()) if prompts_dict else 0 - return server_count, tool_count, prompt_count, resource_count + return AgentSurfaceCounts( + server_count=server_count, + tool_count=tool_count, + prompt_count=prompt_count, + resource_count=resource_count, + ) diff --git a/src/fast_agent/ui/prompt/completer.py b/src/fast_agent/ui/prompt/completer.py index 5eb735b53..aa54fff31 100644 --- a/src/fast_agent/ui/prompt/completer.py +++ b/src/fast_agent/ui/prompt/completer.py @@ -545,7 +545,7 @@ def _complete_local_skill_names( ) def _complete_skill_registries(self, partial: str): - """Generate completions for configured skills registries.""" + """Generate completions for configured and MCP-backed skills registries.""" from fast_agent.skills.configuration import ( format_marketplace_display_url, resolve_skill_registries, @@ -557,6 +557,64 @@ def _complete_skill_registries(self, partial: str): configured_urls=configured_urls, display_formatter=format_marketplace_display_url, ) + yield from self._complete_mcp_skill_registries(partial, offset=len(configured_urls)) + + def _complete_mcp_skill_registries(self, partial: str, *, offset: int = 0): + """Generate completions for live MCP skill registries.""" + cache_key = ( + "mcp_skill_registries", + self.current_agent, + partial, + offset, + ) + cached = self._completion_cache_get(cache_key) + if cached is not None: + yield from cached + return + + choices = self._run_async_completion(self._list_mcp_skill_registry_choices()) or [] + partial_lower = partial.lower() + include_numbers = not partial or partial.isdigit() + include_servers = bool(partial) and not partial.isdigit() + completions: list[Completion] = [] + + for index, (server_name, display, skill_count) in enumerate(choices, offset + 1): + skill_word = "skill" if skill_count == 1 else "skills" + meta = f"{display} ({skill_count:,} {skill_word})" + index_text = str(index) + if include_numbers and index_text.startswith(partial): + completions.append( + Completion( + index_text, + start_position=-len(partial), + display=index_text, + display_meta=meta, + ) + ) + if include_servers: + if partial_lower.startswith("mcp://"): + source = f"mcp://{server_name}" + if source.lower().startswith(partial_lower): + completions.append( + Completion( + source, + start_position=-len(partial), + display=server_name, + display_meta=meta, + ) + ) + elif server_name.lower().startswith(partial_lower): + completions.append( + Completion( + server_name, + start_position=-len(partial), + display=server_name, + display_meta=meta, + ) + ) + + self._completion_cache_put(cache_key, completions) + yield from completions def _complete_registry_urls( self, @@ -1265,6 +1323,37 @@ async def _list_server_resource_uris(self, server_name: str) -> list[str]: uris = result.get(server_name, []) if isinstance(result, dict) else [] return [str(uri) for uri in uris] + async def _list_mcp_skill_registry_choices(self) -> list[tuple[str, str, int]]: + agent = self._current_agent_object() + if agent is None: + return [] + + aggregator = getattr(agent, "aggregator", None) + list_registries = getattr(aggregator, "list_mcp_skill_registries", None) + if not callable(list_registries): + return [] + + try: + registries = await list_registries() + except Exception: + return [] + + choices: list[tuple[str, str, int]] = [] + for registry in registries: + server_name = getattr(registry, "server_name", None) + if not isinstance(server_name, str) or not server_name: + continue + display_name = getattr(registry, "display_name", None) + display = display_name if isinstance(display_name, str) else f"mcp-server {server_name}" + skills = getattr(registry, "skills", ()) + try: + skill_count = len(skills) + except TypeError: + skill_count = 0 + choices.append((server_name, display, skill_count)) + + return sorted(choices, key=lambda choice: choice[0].lower()) + async def _list_server_resource_templates(self, server_name: str) -> list[ResourceTemplate]: agent = self._current_agent_object() if agent is None: diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index 1ea5a59e2..dc2ea026b 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -10,6 +10,7 @@ from rich.text import Text from fast_agent.core.logging.logger import get_logger +from fast_agent.mcp.tool_result_metadata import get_tool_result_media_preview from fast_agent.tools.apply_patch_tool import extract_apply_patch_input, is_apply_patch_tool_name from fast_agent.ui import console from fast_agent.ui.apply_patch_preview import ( @@ -943,12 +944,13 @@ def show_tool_result( read_additional_message, ) post_content: RenderableType | None = None - if result.content: + image_content = get_tool_result_media_preview(result) or result.content + if image_content: from fast_agent.ui.terminal_images import render_tool_result_images post_content = render_tool_result_images( self._display.config, - result.content, + image_content, ) if has_structured: diff --git a/tests/unit/fast_agent/commands/test_command_discovery.py b/tests/unit/fast_agent/commands/test_command_discovery.py index 7bad57b1b..f5dbc073a 100644 --- a/tests/unit/fast_agent/commands/test_command_discovery.py +++ b/tests/unit/fast_agent/commands/test_command_discovery.py @@ -29,7 +29,7 @@ def test_render_command_detail_markdown_contains_registry_action() -> None: assert rendered is not None assert "`registry`" in rendered - assert "/skills registry []" in rendered + assert "/skills registry []" in rendered def test_render_commands_json_detail_has_schema_version() -> None: diff --git a/tests/unit/fast_agent/commands/test_skills_mcp_registry_handlers.py b/tests/unit/fast_agent/commands/test_skills_mcp_registry_handlers.py new file mode 100644 index 000000000..6dac46be3 --- /dev/null +++ b/tests/unit/fast_agent/commands/test_skills_mcp_registry_handlers.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from hashlib import sha256 + +import pytest + +from fast_agent.commands.context import ( + CommandContext, + NonInteractiveCommandIOBase, + StaticAgentProvider, +) +from fast_agent.commands.handlers.skills import ( + handle_list_marketplace_skills, + handle_set_skills_registry, + handle_update_skill, +) +from fast_agent.config import Settings, SkillsSettings +from fast_agent.skills.mcp_registry import McpRegistrySkill, McpSkillRegistry +from fast_agent.skills.provenance import ( + build_mcp_installed_skill_source, + compute_skill_content_fingerprint, + write_installed_skill_source, +) + + +def _digest(text: str) -> str: + return f"sha256:{sha256(text.encode('utf-8')).hexdigest()}" + + +class _Aggregator: + async def list_mcp_skill_registries(self) -> list[McpSkillRegistry]: + return [ + McpSkillRegistry( + server_name="hf", + server_version="1.2.3", + skills=[ + McpRegistrySkill( + name="hub-search", + description="Search the Hub", + source_url="skill://hub-search/SKILL.md", + server_name="hf", + digest=_digest("---\nname: hub-search\ndescription: Search\n---\nv2\n"), + server_version="1.2.3", + ) + ], + ) + ] + + +class _Agent: + aggregator = _Aggregator() + + +def _ctx(settings: Settings) -> CommandContext: + return CommandContext( + agent_provider=StaticAgentProvider({"main": _Agent()}), + current_agent_name="main", + io=NonInteractiveCommandIOBase(), + settings=settings, + ) + + +def _plain(message: object) -> str: + value = getattr(message, "plain", None) + return value if isinstance(value, str) else str(message) + + +@pytest.mark.asyncio +async def test_skills_registry_lists_mcp_servers() -> None: + settings = Settings( + skills=SkillsSettings(marketplace_urls=["https://github.com/example/skills"]) + ) + + outcome = await handle_set_skills_registry(_ctx(settings), agent_name="main", argument=None) + + rendered = "\n".join(_plain(message.text) for message in outcome.messages) + assert "https://github.com/example/skills" in rendered + assert "MCP registries:" in rendered + assert "mcp-server hf@1.2.3" in rendered + + +@pytest.mark.asyncio +async def test_skills_registry_can_select_mcp_server_by_name() -> None: + settings = Settings() + + outcome = await handle_set_skills_registry(_ctx(settings), agent_name="main", argument="hf") + + rendered = "\n".join(_plain(message.text) for message in outcome.messages) + assert settings.skills.marketplace_url == "mcp://hf" + assert "Registry set to: mcp-server hf@1.2.3" in rendered + assert "Skills discovered: 1" in rendered + + +@pytest.mark.asyncio +async def test_skills_registry_filters_active_mcp_source_from_configured_numbers() -> None: + settings = Settings( + skills=SkillsSettings( + marketplace_url="mcp://hf", + marketplace_urls=["https://github.com/example/skills"], + ) + ) + + list_outcome = await handle_set_skills_registry(_ctx(settings), agent_name="main", argument=None) + rendered_list = "\n".join(_plain(message.text) for message in list_outcome.messages) + + assert "https://github.com/example/skills" in rendered_list + assert "mcp://hf" not in rendered_list + assert "MCP registries:" in rendered_list + assert "mcp-server hf@1.2.3" in rendered_list + + select_outcome = await handle_set_skills_registry(_ctx(settings), agent_name="main", argument="2") + rendered_select = "\n".join(_plain(message.text) for message in select_outcome.messages) + + assert settings.skills.marketplace_url == "mcp://hf" + assert "Registry set to: mcp-server hf@1.2.3" in rendered_select + assert "Failed to load registry" not in rendered_select + + +@pytest.mark.asyncio +async def test_skills_available_uses_selected_mcp_registry() -> None: + settings = Settings(skills=SkillsSettings(marketplace_url="mcp://hf")) + + outcome = await handle_list_marketplace_skills( + _ctx(settings), agent_name="main", query=None + ) + + rendered = "\n".join(_plain(message.text) for message in outcome.messages) + assert "MCP skills from mcp-server hf@1.2.3" in rendered + assert "hub-search" in rendered + + +@pytest.mark.asyncio +async def test_skills_update_reports_mcp_digest_update_available(tmp_path) -> None: + environment_dir = tmp_path / ".fast-agent" + skill_dir = environment_dir / "skills" / "hub-search" + skill_dir.mkdir(parents=True) + skill_text = "---\nname: hub-search\ndescription: Search\n---\nv1\n" + (skill_dir / "SKILL.md").write_text(skill_text, encoding="utf-8") + fingerprint = compute_skill_content_fingerprint(skill_dir) + write_installed_skill_source( + skill_dir, + build_mcp_installed_skill_source( + server_name="hf", + server_version="1.2.3", + skill_uri="skill://hub-search/SKILL.md", + fingerprint=fingerprint, + artifact_digest=_digest(skill_text), + artifact_type="skill-md", + ), + ) + settings = Settings( + environment_dir=str(environment_dir), + skills=SkillsSettings(), + ) + + outcome = await handle_update_skill(_ctx(settings), agent_name="main", argument=None) + + rendered = "\n".join(_plain(message.text) for message in outcome.messages) + assert "hub-search" in rendered + assert "update available" in rendered diff --git a/tests/unit/fast_agent/skills/test_mcp_registry.py b/tests/unit/fast_agent/skills/test_mcp_registry.py new file mode 100644 index 000000000..0cea33561 --- /dev/null +++ b/tests/unit/fast_agent/skills/test_mcp_registry.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import base64 +import json +import tarfile +from hashlib import sha256 +from io import BytesIO + +import pytest +from mcp.types import ( + BlobResourceContents, + ReadResourceResult, + ServerCapabilities, + TextResourceContents, +) +from pydantic import AnyUrl + +from fast_agent.skills.mcp_registry import ( + INDEX_URI, + McpRegistrySkill, + install_mcp_registry_skill, + scan_mcp_skill_registry, + server_supports_mcp_skills, +) +from fast_agent.skills.provenance import read_installed_skill_source + + +def _text(uri: str, body: str) -> ReadResourceResult: + return ReadResourceResult( + contents=[TextResourceContents(uri=AnyUrl(uri), mimeType="text/plain", text=body)] + ) + + +def _blob(uri: str, body: bytes) -> ReadResourceResult: + return ReadResourceResult( + contents=[ + BlobResourceContents( + uri=AnyUrl(uri), + mimeType="application/octet-stream", + blob=base64.b64encode(body).decode("ascii"), + ) + ] + ) + + +def _digest(body: bytes | str) -> str: + data = body.encode("utf-8") if isinstance(body, str) else body + return f"sha256:{sha256(data).hexdigest()}" + + +class _Aggregator: + def __init__( + self, + *, + capabilities: ServerCapabilities, + responses: dict[str, str | bytes], + ) -> None: + self.capabilities = capabilities + self.responses = responses + + async def get_capabilities(self, server_name: str) -> ServerCapabilities: + del server_name + return self.capabilities + + async def get_resource( + self, resource_uri: str, *, server_name: str | None = None + ) -> ReadResourceResult: + del server_name + response = self.responses[resource_uri] + if isinstance(response, bytes): + return _blob(resource_uri, response) + return _text(resource_uri, response) + + +def _skills_capabilities() -> ServerCapabilities: + return ServerCapabilities.model_validate( + {"extensions": {"io.modelcontextprotocol/skills": {}}} + ) + + +def _tar_gz(files: dict[str, bytes]) -> bytes: + archive_bytes = BytesIO() + with tarfile.open(fileobj=archive_bytes, mode="w:gz") as archive: + for name, content in files.items(): + info = tarfile.TarInfo(name) + info.size = len(content) + archive.addfile(info, BytesIO(content)) + return archive_bytes.getvalue() + + +def test_server_supports_mcp_skills_from_extensions_extra() -> None: + assert server_supports_mcp_skills(_skills_capabilities()) + assert not server_supports_mcp_skills(ServerCapabilities()) + + +@pytest.mark.asyncio +async def test_scan_mcp_skill_registry_requires_capability() -> None: + index = json.dumps( + { + "skills": [ + { + "type": "skill-md", + "name": "demo", + "description": "Demo skill", + "url": "skill://demo/SKILL.md", + "digest": "sha256:" + "0" * 64, + } + ] + } + ) + aggregator = _Aggregator(capabilities=ServerCapabilities(), responses={INDEX_URI: index}) + + assert await scan_mcp_skill_registry(aggregator, "demo") is None + + +@pytest.mark.asyncio +async def test_scan_mcp_skill_registry_reads_verified_skill_entries() -> None: + skill_text = "---\nname: demo\ndescription: Demo skill\n---\nBody\n" + archive = _tar_gz({"SKILL.md": skill_text.encode("utf-8")}) + index = json.dumps( + { + "skills": [ + { + "type": "skill-md", + "name": "demo", + "description": "Demo skill", + "url": "skill://demo/SKILL.md", + "digest": _digest(skill_text), + }, + { + "type": "archive", + "name": "bundle", + "description": "Bundled skill", + "url": "bundle/bundle.tar.gz", + "digest": _digest(archive), + }, + { + "type": "mcp-resource-template", + "description": "Template", + "url": "skill://docs/{product}/SKILL.md", + }, + ] + } + ) + aggregator = _Aggregator(capabilities=_skills_capabilities(), responses={INDEX_URI: index}) + + registry = await scan_mcp_skill_registry(aggregator, "hf", server_version="1.2.3") + + assert registry is not None + assert registry.server_name == "hf" + assert registry.server_version == "1.2.3" + assert [skill.name for skill in registry.skills] == ["demo", "bundle"] + assert registry.skills[0].source_url == "skill://demo/SKILL.md" + assert registry.skills[0].digest == _digest(skill_text) + assert registry.skills[1].artifact_type == "archive" + assert registry.skills[1].source_url == "skill://bundle/bundle.tar.gz" + + +@pytest.mark.asyncio +async def test_scan_mcp_skill_registry_skips_entries_without_sha256() -> None: + index = json.dumps( + { + "skills": [ + { + "type": "skill-md", + "name": "demo", + "description": "Demo skill", + "url": "skill://demo/SKILL.md", + } + ] + } + ) + aggregator = _Aggregator(capabilities=_skills_capabilities(), responses={INDEX_URI: index}) + + registry = await scan_mcp_skill_registry(aggregator, "hf") + + assert registry is not None + assert registry.skills == [] + + +@pytest.mark.asyncio +async def test_install_mcp_registry_skill_writes_provenance(tmp_path) -> None: + skill_text = "---\nname: demo\ndescription: Demo skill\n---\nBody\n" + skill = McpRegistrySkill( + name="demo", + description="Demo skill", + source_url="skill://demo/SKILL.md", + server_name="hf", + digest=_digest(skill_text), + server_version="1.2.3", + ) + aggregator = _Aggregator( + capabilities=_skills_capabilities(), + responses={"skill://demo/SKILL.md": skill_text}, + ) + + install_dir = await install_mcp_registry_skill( + aggregator, + skill, + destination_root=tmp_path, + ) + + assert (install_dir / "SKILL.md").read_text(encoding="utf-8") == skill_text + source, error = read_installed_skill_source(install_dir) + assert error is None + assert source is not None + assert source.installed_via == "mcp" + assert source.source_origin == "mcp" + assert source.mcp_server_name == "hf" + assert source.mcp_server_version == "1.2.3" + assert source.source_url == "skill://demo/SKILL.md" + assert source.artifact_digest == _digest(skill_text) + assert source.artifact_type == "skill-md" + + +@pytest.mark.asyncio +async def test_install_mcp_registry_skill_rejects_digest_mismatch(tmp_path) -> None: + skill_text = "---\nname: demo\ndescription: Demo skill\n---\nBody\n" + skill = McpRegistrySkill( + name="demo", + description="Demo skill", + source_url="skill://demo/SKILL.md", + server_name="hf", + digest="sha256:" + "0" * 64, + ) + aggregator = _Aggregator( + capabilities=_skills_capabilities(), + responses={"skill://demo/SKILL.md": skill_text}, + ) + + with pytest.raises(ValueError, match="SHA256 mismatch"): + await install_mcp_registry_skill(aggregator, skill, destination_root=tmp_path) + + assert not (tmp_path / "demo").exists() + + +@pytest.mark.asyncio +async def test_install_mcp_registry_archive_skill(tmp_path) -> None: + skill_text = "---\nname: demo\ndescription: Demo skill\n---\nBody\n" + artifact = _tar_gz( + { + "SKILL.md": skill_text.encode("utf-8"), + "scripts/run.sh": b"echo ok\n", + } + ) + skill = McpRegistrySkill( + name="demo", + description="Demo skill", + source_url="skill://demo/demo.tar.gz", + server_name="hf", + digest=_digest(artifact), + artifact_type="archive", + ) + aggregator = _Aggregator( + capabilities=_skills_capabilities(), + responses={"skill://demo/demo.tar.gz": artifact}, + ) + + install_dir = await install_mcp_registry_skill(aggregator, skill, destination_root=tmp_path) + + assert (install_dir / "SKILL.md").read_text(encoding="utf-8") == skill_text + assert (install_dir / "scripts" / "run.sh").read_text(encoding="utf-8") == "echo ok\n" diff --git a/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py b/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py index 19bc6bf0a..8c8257f97 100644 --- a/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py +++ b/tests/unit/fast_agent/tools/test_local_filesystem_runtime.py @@ -12,6 +12,7 @@ from fast_agent.llm.model_info import ModelInfo from fast_agent.llm.provider_types import Provider +from fast_agent.mcp.tool_result_metadata import get_tool_result_media_preview from fast_agent.tools.local_filesystem_runtime import LocalFilesystemRuntime @@ -133,6 +134,10 @@ async def test_attach_media_local_png_stages_image_content(tmp_path: Path) -> No assert result.content is not None assert isinstance(result.content[0], TextContent) assert len(result.content) == 1 + preview = get_tool_result_media_preview(result) + assert preview is not None + assert len(preview) == 1 + assert isinstance(preview[0], ImageContent) pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], ImageContent) @@ -155,6 +160,7 @@ async def test_attach_media_local_pdf_stages_embedded_blob(tmp_path: Path) -> No assert result.isError is False assert result.content is not None assert len(result.content) == 1 + assert get_tool_result_media_preview(result) is None pending = runtime.consume_pending_media_attachments() assert len(pending) == 1 assert isinstance(pending[0], EmbeddedResource) @@ -567,4 +573,3 @@ def test_attach_media_tool_description_conditional() -> None: openai_tool = _tool_by_name(openai_runtime, "attach_media") assert openai_tool is not None assert "Gemini YouTube links" not in openai_tool.description - diff --git a/tests/unit/fast_agent/ui/test_agent_completer.py b/tests/unit/fast_agent/ui/test_agent_completer.py index db2d6867f..6cc6dff69 100644 --- a/tests/unit/fast_agent/ui/test_agent_completer.py +++ b/tests/unit/fast_agent/ui/test_agent_completer.py @@ -28,6 +28,7 @@ from fast_agent.llm.reasoning_effort import ReasoningEffortSetting, ReasoningEffortSpec from fast_agent.mcp.prompt_message_extended import PromptMessageExtended from fast_agent.session import get_session_manager, reset_session_manager +from fast_agent.skills.mcp_registry import McpRegistrySkill, McpSkillRegistry from fast_agent.skills.models import ( DEFAULT_SKILL_REGISTRIES, InstalledSkillSource, @@ -173,6 +174,39 @@ def __init__(self) -> None: self.aggregator = _MentionFilteredAggregatorStub() +class _McpSkillRegistryAggregatorStub: + async def list_mcp_skill_registries(self) -> list[McpSkillRegistry]: + return [ + McpSkillRegistry( + server_name="hf", + server_version="1.2.3", + skills=[ + McpRegistrySkill( + name="datasets", + description="Work with datasets", + source_url="skill://datasets/SKILL.md", + server_name="hf", + digest="sha256:" + "0" * 64, + server_version="1.2.3", + ), + McpRegistrySkill( + name="spaces", + description="Work with Spaces", + source_url="skill://spaces/SKILL.md", + server_name="hf", + digest="sha256:" + "1" * 64, + server_version="1.2.3", + ), + ], + ) + ] + + +class _McpSkillRegistryAgentStub: + def __init__(self) -> None: + self.aggregator = _McpSkillRegistryAggregatorStub() + + class _HistoryAgentStub: def __init__(self, turn_count: int) -> None: self.message_history = [ @@ -1270,6 +1304,58 @@ def test_get_completions_for_skills_registry_supports_file_paths( update_global_settings(old_settings) +def test_get_completions_for_skills_registry_includes_mcp_registry() -> None: + old_settings = get_settings() + override = old_settings.model_copy( + update={ + "skills": SkillsSettings( + marketplace_urls=["https://example.com/registry-one.json"], + ) + } + ) + update_global_settings(override) + try: + completer = AgentCompleter( + agents=["agent1"], + current_agent="agent1", + agent_provider=cast("AgentApp", _ProviderStub(_McpSkillRegistryAgentStub())), + ) + doc = Document("/skills registry ", cursor_position=len("/skills registry ")) + completions = list(completer.get_completions(doc, None)) + meta_by_text = {completion.text: completion.display_meta_text for completion in completions} + + assert "1" in meta_by_text + assert meta_by_text["2"] == "mcp-server hf@1.2.3 (2 skills)" + finally: + update_global_settings(old_settings) + + +def test_get_completions_for_skills_registry_completes_mcp_server_name() -> None: + completer = AgentCompleter( + agents=["agent1"], + current_agent="agent1", + agent_provider=cast("AgentApp", _ProviderStub(_McpSkillRegistryAgentStub())), + ) + doc = Document("/skills registry h", cursor_position=len("/skills registry h")) + completions = list(completer.get_completions(doc, None)) + names = [completion.text for completion in completions] + + assert "hf" in names + + +def test_get_completions_for_skills_registry_completes_mcp_source_uri() -> None: + completer = AgentCompleter( + agents=["agent1"], + current_agent="agent1", + agent_provider=cast("AgentApp", _ProviderStub(_McpSkillRegistryAgentStub())), + ) + doc = Document("/skills registry mcp://h", cursor_position=len("/skills registry mcp://h")) + completions = list(completer.get_completions(doc, None)) + names = [completion.text for completion in completions] + + assert "mcp://hf" in names + + def test_get_completions_for_skills_update_only_managed(): with tempfile.TemporaryDirectory() as tmpdir: skills_root = Path(tmpdir) / "skills" diff --git a/tests/unit/fast_agent/ui/test_agent_info.py b/tests/unit/fast_agent/ui/test_agent_info.py new file mode 100644 index 000000000..edcfac354 --- /dev/null +++ b/tests/unit/fast_agent/ui/test_agent_info.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast + +import pytest +from mcp.types import ListToolsResult, Tool + +from fast_agent.ui.prompt import agent_info +from fast_agent.ui.prompt.agent_info import ( + AgentSurfaceCounts, + _child_agent_counts, + _mcp_server_resource_counts, +) + +if TYPE_CHECKING: + from fast_agent.mcp.types import McpAgentProtocol + + +class _AggregatorStub: + server_names = ["hf"] + + async def list_tools(self) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool(name="hf.tool_1", inputSchema={}), + Tool(name="hf.tool_2", inputSchema={}), + ] + ) + + async def list_resources(self) -> dict[str, list[str]]: + return {"hf": ["skill://index.json", "skill://datasets/SKILL.md"]} + + async def list_prompts(self): + return {"hf": [object()]} + + +class _McpChildAgentStub: + name = "child" + aggregator = _AggregatorStub() + + +@pytest.mark.asyncio +async def test_mcp_server_resource_counts_use_aggregator_only() -> None: + agent = cast( + "McpAgentProtocol", + SimpleNamespace( + aggregator=_AggregatorStub(), + ), + ) + + assert await _mcp_server_resource_counts(agent) == AgentSurfaceCounts( + tool_count=2, + prompt_count=1, + resource_count=2, + ) + + +@pytest.mark.asyncio +async def test_child_agent_counts_keep_prompt_and_resource_order(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(agent_info, "McpAgentProtocol", _McpChildAgentStub) + + counts = await _child_agent_counts(cast("McpAgentProtocol", _McpChildAgentStub())) + + assert counts == AgentSurfaceCounts( + server_count=1, + tool_count=2, + prompt_count=1, + resource_count=2, + ) diff --git a/tests/unit/fast_agent/ui/test_mcp_display.py b/tests/unit/fast_agent/ui/test_mcp_display.py index 6f0f1211c..5a1b4ccce 100644 --- a/tests/unit/fast_agent/ui/test_mcp_display.py +++ b/tests/unit/fast_agent/ui/test_mcp_display.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta, timezone import pytest +from click.utils import strip_ansi from rich.console import Console from fast_agent.mcp.mcp_aggregator import ServerStatus @@ -215,6 +216,54 @@ async def test_render_mcp_status_renders_server_details_and_calls() -> None: assert "session" in output +@pytest.mark.asyncio +async def test_render_mcp_status_shows_skills_hint_above_capability_bar() -> None: + agent = _FakeAgent( + { + "skills-server": ServerStatus( + server_name="skills-server", + is_connected=True, + staleness_seconds=202, + transport="stdio", + mcp_skills_enabled=True, + transport_channels=TransportSnapshot( + activity_bucket_seconds=30, + activity_bucket_count=4, + stdio=ChannelSnapshot( + state="connected", + message_count=1, + request_count=1, + response_count=1, + notification_count=0, + activity_buckets=["request", "response"], + ), + ), + ) + }, + instruction="", + ) + + original_console = _set_console_size(width=120) + try: + with console.console.capture() as capture: + await render_mcp_status(agent, indent=" ") + output = strip_ansi(capture.get()) + finally: + _restore_console_size(original_console) + + assert "last activity:" in output + assert "last activity:" in output and "SEP-2640" in output + assert "last activity:" not in next(line for line in output.splitlines() if "SEP-2640" in line) + + lines = output.splitlines() + transport_index = next(index for index, line in enumerate(lines) if "STDIO" in line) + skills_index = next(index for index, line in enumerate(lines) if "SEP-2640" in line) + capability_index = next(index for index, line in enumerate(lines) if "─| " in line) + + assert transport_index < skills_index < capability_index + assert " Sk " in lines[capability_index] or " Sk" in lines[capability_index] + + @pytest.mark.asyncio async def test_render_mcp_status_reports_when_no_server_status_is_available() -> None: class _NoStatusAgent: diff --git a/tests/unit/fast_agent/ui/test_terminal_images.py b/tests/unit/fast_agent/ui/test_terminal_images.py index e39f5a8db..6073aaaf7 100644 --- a/tests/unit/fast_agent/ui/test_terminal_images.py +++ b/tests/unit/fast_agent/ui/test_terminal_images.py @@ -4,6 +4,10 @@ from fast_agent.config import LoggerSettings, Settings, TerminalImageSettings from fast_agent.mcp.prompt_render import render_content_blocks +from fast_agent.mcp.tool_result_metadata import ( + get_tool_result_media_preview, + set_tool_result_media_preview, +) from fast_agent.ui.console_display import ConsoleDisplay from fast_agent.ui.terminal_images import ( extract_image_artifacts, @@ -73,6 +77,23 @@ def test_tool_result_images_render_without_console_display_state() -> None: assert render_tool_result_images(config, [_image_content()]) is not None +def test_tool_result_media_preview_is_display_only() -> None: + from mcp.types import CallToolResult + + result = CallToolResult( + content=[TextContent(type="text", text="Staged image for the next model call.")], + isError=False, + ) + + set_tool_result_media_preview(result, [_image_content()]) + + assert len(result.content) == 1 + preview = get_tool_result_media_preview(result) + assert preview is not None + assert len(preview) == 1 + assert isinstance(preview[0], ImageContent) + + def test_tool_result_image_rendering_does_not_create_console_display_state() -> None: display = ConsoleDisplay( Settings( diff --git a/uv.lock b/uv.lock index 659a93bab..5d755e93d 100644 --- a/uv.lock +++ b/uv.lock @@ -760,7 +760,7 @@ requires-dist = [{ name = "fast-agent-mcp", editable = "." }] [[package]] name = "fast-agent-mcp" -version = "0.7.14" +version = "0.7.15" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },