diff --git a/core/services/skills/skills_mcp_test.go b/core/services/skills/skills_mcp_test.go new file mode 100644 index 000000000000..6cfe2fa543f3 --- /dev/null +++ b/core/services/skills/skills_mcp_test.go @@ -0,0 +1,115 @@ +package skills_test + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + agiSkills "github.com/mudler/LocalAGI/services/skills" + localskills "github.com/mudler/LocalAI/core/services/skills" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSkillsMCP(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Skills MCP test") +} + +// listSkillsResult mirrors the output struct of skillserver's list_skills tool. +type listSkillsResult struct { + Skills []struct { + ID string `json:"id"` + Description string `json:"description,omitempty"` + } `json:"skills"` +} + +// Exercises the same wire the agent uses at runtime: open an in-process +// MCP session via LocalAGI's skills.Service, create a skill through the +// LocalAI FilesystemManager, then list_skills on the still-open session. +// Guards against regressions in the manager <-> MCP session lifecycle +// (e.g. cached manager not picking up newly-created skills). +var _ = Describe("Skills exposed to agent via MCP", func() { + var ( + stateDir string + svc *agiSkills.Service + ctx context.Context + cancel context.CancelFunc + ) + + BeforeEach(func() { + var err error + stateDir, err = os.MkdirTemp("", "skills-mcp-test") + Expect(err).NotTo(HaveOccurred()) + + // Create the LocalAGI skills service (this is what AgentPoolService wires + // into LocalAGI's state.NewAgentPool for MCP session exposure). + svc, err = agiSkills.NewService(stateDir) + Expect(err).NotTo(HaveOccurred()) + + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + }) + + AfterEach(func() { + cancel() + Expect(os.RemoveAll(stateDir)).To(Succeed()) + }) + + It("returns a skill created after the MCP session was established", func() { + // Open the MCP session first — this is what the agent does at startup + // with EnableSkills=true, before any skill might exist. + session, err := svc.GetMCPSession(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(session).NotTo(BeNil()) + + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "list_skills"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.IsError).To(BeFalse()) + var initial listSkillsResult + Expect(decodeMCPText(res, &initial)).To(Succeed()) + Expect(initial.Skills).To(BeEmpty(), "no skills should exist initially") + + // Create a skill via the LocalAI FilesystemManager — same code path the + // /api/agents/skills POST endpoint takes. + mgr := localskills.NewFilesystemManager(svc) + _, err = mgr.Create("talk-like-pirate", "Talk like a pirate", "Speak in pirate-style.", "", "", "", nil) + Expect(err).NotTo(HaveOccurred()) + + // Re-list via the SAME already-open session: the manager is shared, + // so a freshly-created skill must be visible without re-attaching. + res, err = session.CallTool(ctx, &mcp.CallToolParams{Name: "list_skills"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.IsError).To(BeFalse()) + + var got listSkillsResult + Expect(decodeMCPText(res, &got)).To(Succeed()) + + ids := make([]string, 0, len(got.Skills)) + for _, s := range got.Skills { + ids = append(ids, s.ID) + } + Expect(ids).To(ContainElement("talk-like-pirate")) + }) +}) + +func mcpText(res *mcp.CallToolResult) string { + text := "" + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + text += tc.Text + } + } + return text +} + +func decodeMCPText(res *mcp.CallToolResult, out any) error { + text := mcpText(res) + if text == "" { + return nil + } + return json.Unmarshal([]byte(text), out) +} diff --git a/go.mod b/go.mod index d0d3c233ef7b..ff5ee642ec70 100644 --- a/go.mod +++ b/go.mod @@ -220,7 +220,7 @@ require ( github.com/mschoch/smat v0.2.0 // indirect github.com/mudler/LocalAGI v0.0.0-20260508125235-37810d918a87 github.com/mudler/localrecall v0.6.1-0.20260507074622-a7724fef6f81 // indirect - github.com/mudler/skillserver v0.0.6 + github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145 github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect github.com/philippgille/chromem-go v0.7.0 // indirect diff --git a/go.sum b/go.sum index 3978d0e14d7c..52a2bdedfdd4 100644 --- a/go.sum +++ b/go.sum @@ -984,6 +984,10 @@ github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8 h1:Ry8RiWy8fZ6Ff4E7d github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8= github.com/mudler/skillserver v0.0.6 h1:ixz6wUekLdTmbnpAavCkTydDF6UdXAG3ncYufSPK9G0= github.com/mudler/skillserver v0.0.6/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU= +github.com/mudler/skillserver v0.0.7-0.20260520212528-3dae7f041b1e h1:ryXE1UEzGhLkDFYuaxJ0fZ6fg4l++TWfMCTJ1E7bYS8= +github.com/mudler/skillserver v0.0.7-0.20260520212528-3dae7f041b1e/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU= +github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145 h1:z59tA3IDYPt71nzH1jpxeaA1LuDw8aZfpTQFNU43Zb8= +github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU= github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025 h1:WFLP5FHInarYGXi6B/Ze204x7Xy6q/I4nCZnWEyPHK0= github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025/go.mod h1:QuIFdRstyGJt+MTTkWY+mtD7U6xwjOR6SwKUjmLZtR4= github.com/mudler/xlog v0.0.6 h1:3nBV4THK8kY0Y8FDXXvWAnuAJoOyO7EAXteJeAoHUC0=