docs(skills): explain and verify MCP dependency readiness

This commit is contained in:
xiexian committed 2026-10-01 14:45:56 +08:00
1 parent 4bcda5691d
commit 9d930f67f4
2 files changed
+226 -1

No files matched your search

+41 -1
View File
@@ -77,7 +77,7 @@ Each skill's body is loaded when that skill is invoked.
| `read-only` | Run a subagent skill with writer tools removed and read-only shell. |
| `invocation` | `manual` keeps the skill out of the model's listing; it stays callable by name unless model invocation is disabled. |
| `disable-model-invocation` | `true` prevents model calls; the user can still invoke the skill explicitly. |
| `requires` | Capabilities the skill needs, e.g. `mcp-server:github`. |
| `requires` | Ready capabilities required for model invocation, e.g. `mcp-server:github`. See the MCP requirements example below. |
Unknown keys are ignored, so a skill written for another agent loads as-is.
@@ -98,6 +98,46 @@ disabled_skills = ["review"] # hidden until /skills enable
Skills also arrive inside plugin packages; see [PLUGIN_PACKAGES.md](PLUGIN_PACKAGES.md).
## Declaring MCP requirements
1. Use the configured server name and its actual tool names. For a server named
`notes` exposing `read`, a skill can declare both requirements:
```markdown
---
name: notes-check
description: Check notes using the configured MCP reader
requires: mcp-server:notes, mcp-tool:notes/read
---
Read the notes available from the configured notes server.
Report the relevant entries and identify anything that could not be verified.
```
2. Confirm IDs in the session's capability catalog: ask the model to inspect
`mcp-server:notes` with `use_capability`, then copy the returned IDs.
The reader must already be configured; `requires` does not install it,
supply credentials, or grant permission.
3. Model calls through `run_skill`, `read_skill`, `use_capability` and
`slash_command` require every declared capability to be `ready`. Missing
tools, disabled servers and cached schemas without a live connection do not
qualify. Correct IDs or connect the enabled server in Studio, then retry.
4. An enabled server with no usable schema cache may connect at startup to
discover its tools. Once schemas are cached, a deferred server can remain
disconnected until needed; its cached tools alone do not make a required
capability `ready`.
5. A user explicitly typing `/notes-check` can still load the playbook when its
dependencies are unavailable, for example to ask about setup. That does not
connect a disabled server or bypass the tool's own execution checks.
Write the body so it explains missing setup rather than claiming a result.
6. Use [capability diagnostics](CAPABILITY_DIAGNOSTICS.md) to check configuration;
its live probe is separate from an open session's connection. `reasonix doctor`
warns about missing or host-failed MCP servers for `auto-use: require` skills.
A clean report does not prove runtime readiness.
## Sharing a skill with a repository
Commit a project skill so collaborators receive the same playbook with the
@@ -0,0 +1,185 @@
package boot
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
func TestEffectSkillMCPRequirementsFollowConnectionAndActivation(t *testing.T) {
home := isolateConfigHome(t)
t.Setenv("REASONIX_HOME", filepath.Join(home, ".reasonix"))
workspace := robustTempDir(t)
t.Chdir(workspace)
server := disabledToolsMCPServer(t)
defer server.Close()
const body = "Read the notes available from the configured notes server.\nReport the relevant entries and identify anything that could not be verified."
const skillPath = ".agents/skills/notes-check/SKILL.md"
const source = "---\nname: notes-check\ndescription: Check notes using the configured MCP reader\nrequires: mcp-server:notes, mcp-tool:notes/read\n---\n" + body + "\n"
writeFile(t, workspace, skillPath, source)
writeFile(t, workspace, "reasonix.toml", fmt.Sprintf(`
default_model = "test-model"
[agent]
system_prompt = "BASE"
[environment]
enabled = false
[codegraph]
enabled = false
[[providers]]
name = "test-model"
kind = "boot-skill-mcp-requires"
model = "x"
[[plugins]]
name = "notes"
type = "http"
url = %q
auto_start = false
load = "deferred"
`, server.URL))
approveWorkspace(t, workspace)
approveProjectServer(t, workspace, "notes")
var rec *scriptedCallProvider
provider.Register("boot-skill-mcp-requires", func(provider.Config) (provider.Provider, error) { return rec, nil })
var ctrl *control.Controller
restart := func() {
t.Helper()
if ctrl != nil {
ctrl.Close()
}
rec = &scriptedCallProvider{}
var err error
ctrl, err = Build(t.Context(), Options{Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
ctrl.EnsureSessionPath()
}
t.Cleanup(func() {
if ctrl != nil {
ctrl.Close()
}
})
connect := func() {
t.Helper()
entries := ctrl.ConfiguredMCPServers()
if len(entries) != 1 || entries[0].Entry.Name != "notes" {
t.Fatalf("configured MCP servers = %+v", entries)
}
if n, err := ctrl.ConnectMCPServer(entries[0].Entry); err != nil || n != 2 {
t.Fatalf("connect notes: tools=%d, err=%v", n, err)
}
}
calls := []scriptedCall{
{"run_skill", `{"name":"notes-check","arguments":"check today's notes"}`},
{"read_skill", `{"name":"notes-check"}`},
{"use_capability", `{"action":"call","capability_id":"skill:notes-check","arguments":{"arguments":"check today's notes"}}`},
{"slash_command", `{"command":"notes-check","arguments":"check today's notes"}`},
}
var prefix string
phase := func(name string, ready, connected bool) {
t.Helper()
t.Run(name, func(t *testing.T) {
if live := len(ctrl.Host().Servers()) > 0; live != connected {
t.Fatalf("MCP connected=%t, expected=%t", live, connected)
}
rec.mu.Lock()
first := rec.round
for len(rec.calls) < first {
rec.calls = append(rec.calls, scriptedCall{})
}
rec.calls = append(rec.calls, calls...)
rec.mu.Unlock()
if err := ctrl.Run(t.Context(), "Inspect the notes-check playbook"); err != nil {
t.Fatal(err)
}
for i, call := range calls {
result := rec.resultOf(first + i)
if ready {
if !strings.Contains(result, body) {
t.Errorf("%s did not return the ready skill body: %s", call.name, result)
}
} else if strings.Contains(result, body) || !strings.Contains(result, "requires unavailable capabilities:") {
t.Errorf("%s did not refuse the unavailable dependency: %s", call.name, result)
}
}
rec.mu.Lock()
requests := append([]provider.Request(nil), rec.reqs...)
rec.mu.Unlock()
for _, req := range requests {
current := systemMessage(req.Messages)
if strings.Contains(current, body) {
t.Fatal("dependency-bound skill body entered the cached system prefix")
}
if prefix == "" {
prefix = current
} else if current != prefix {
t.Fatal("MCP dependency state changed the cached system prefix")
}
}
})
}
restart()
waitForMCPServer(t, ctrl.Host(), "notes")
waitForCond(t, "discovered notes tool catalog", 10*time.Second, func() bool { return ctrl.MCPCatalogTools()["notes"] == 2 })
phase("connected-after-first-schema-discovery", true, true)
writeFile(t, workspace, skillPath, strings.Replace(source, "mcp-tool:notes/read", "mcp-tool:notes/not-offered", 1))
phase("connected-with-an-unavailable-tool", false, true)
writeFile(t, workspace, skillPath, source)
phase("corrected-tool-declaration-live", true, true)
if err := ctrl.SetMCPServerEnabled("notes", config.ActivationProject, false); err != nil {
t.Fatal(err)
}
phase("disabled-live", false, false)
restart()
phase("disabled-after-restart", false, false)
if err := ctrl.SetMCPServerEnabled("notes", config.ActivationProject, true); err != nil {
t.Fatal(err)
}
phase("enabled-with-cache-before-connection", false, false)
connect()
phase("enabled-and-connected", true, true)
restart()
if entries := ctrl.ConfiguredMCPServers(); len(entries) != 1 || len(entries[0].Tools) != 2 || entries[0].Stale {
t.Fatalf("expected two valid cached tools after restart: %+v", entries)
}
phase("cached-without-connection", false, false)
connect()
phase("reconnected-after-restart", true, true)
if err := ctrl.SetMCPServerEnabled("notes", config.ActivationProject, false); err != nil {
t.Fatal(err)
}
rec.mu.Lock()
count := len(rec.reqs)
rec.mu.Unlock()
ctrl.SubmitHTTPOptions("/notes-check explain the missing setup", control.SubmitOptions{RefuseUnknownSlash: true})
waitForCond(t, "explicit skill invocation", 10*time.Second, func() bool { return !ctrl.Running() })
rec.mu.Lock()
requests := append([]provider.Request(nil), rec.reqs...)
rec.mu.Unlock()
if len(requests) <= count {
t.Fatal("explicit user invocation did not reach the provider")
}
var user string
for _, msg := range requests[len(requests)-1].Messages {
if msg.Role == provider.RoleUser {
user = msg.Content
}
}
if !strings.Contains(user, body) || !strings.Contains(user, "Arguments: explain the missing setup") {
t.Fatalf("explicit invocation was not delivered: %s", user)
}
if len(ctrl.Host().Servers()) != 0 {
t.Fatal("explicit skill invocation connected a disabled MCP server")
}
if got, err := os.ReadFile(filepath.Join(workspace, filepath.FromSlash(skillPath))); err != nil || string(got) != source {
t.Fatalf("connection changes altered the shared skill: %q, err=%v", got, err)
}
}