diff --git a/docs/docs.go b/docs/docs.go index 7e1ad767..94fdb3c3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -544,6 +544,126 @@ const docTemplate = `{ } } }, + "/mcp/skills": { + "get": { + "tags": [ + "containerd" + ], + "summary": "List skills from container", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "tags": [ + "containerd" + ], + "summary": "Upload skills into container", + "parameters": [ + { + "description": "Skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SkillsUpsertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.skillsOpResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "tags": [ + "containerd" + ], + "summary": "Delete skills from container", + "parameters": [ + { + "description": "Delete skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SkillsDeleteRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.skillsOpResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/mcp/snapshots": { "get": { "tags": [ @@ -2768,6 +2888,53 @@ const docTemplate = `{ } } }, + "handlers.SkillItem": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "handlers.SkillsDeleteRequest": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handlers.SkillsResponse": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.SkillItem" + } + } + } + }, + "handlers.SkillsUpsertRequest": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.SkillItem" + } + } + } + }, "handlers.SnapshotInfo": { "type": "object", "properties": { @@ -2797,6 +2964,14 @@ const docTemplate = `{ } } }, + "handlers.skillsOpResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + }, "history.CreateRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 6cb4e55d..99940e31 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -535,6 +535,126 @@ } } }, + "/mcp/skills": { + "get": { + "tags": [ + "containerd" + ], + "summary": "List skills from container", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SkillsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "tags": [ + "containerd" + ], + "summary": "Upload skills into container", + "parameters": [ + { + "description": "Skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SkillsUpsertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.skillsOpResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "tags": [ + "containerd" + ], + "summary": "Delete skills from container", + "parameters": [ + { + "description": "Delete skills payload", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SkillsDeleteRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.skillsOpResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/mcp/snapshots": { "get": { "tags": [ @@ -2759,6 +2879,53 @@ } } }, + "handlers.SkillItem": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "handlers.SkillsDeleteRequest": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handlers.SkillsResponse": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.SkillItem" + } + } + } + }, + "handlers.SkillsUpsertRequest": { + "type": "object", + "properties": { + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.SkillItem" + } + } + } + }, "handlers.SnapshotInfo": { "type": "object", "properties": { @@ -2788,6 +2955,14 @@ } } }, + "handlers.skillsOpResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + }, "history.CreateRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5c85fb0c..4542091d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -190,6 +190,36 @@ definitions: username: type: string type: object + handlers.SkillItem: + properties: + content: + type: string + description: + type: string + name: + type: string + type: object + handlers.SkillsDeleteRequest: + properties: + names: + items: + type: string + type: array + type: object + handlers.SkillsResponse: + properties: + skills: + items: + $ref: '#/definitions/handlers.SkillItem' + type: array + type: object + handlers.SkillsUpsertRequest: + properties: + skills: + items: + $ref: '#/definitions/handlers.SkillItem' + type: array + type: object handlers.SnapshotInfo: properties: created_at: @@ -209,6 +239,11 @@ definitions: updated_at: type: string type: object + handlers.skillsOpResponse: + properties: + ok: + type: boolean + type: object history.CreateRequest: properties: messages: @@ -1088,6 +1123,84 @@ paths: summary: MCP filesystem tools (JSON-RPC) tags: - containerd + /mcp/skills: + delete: + parameters: + - description: Delete skills payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/handlers.SkillsDeleteRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.skillsOpResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Delete skills from container + tags: + - containerd + get: + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.SkillsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: List skills from container + tags: + - containerd + post: + parameters: + - description: Skills payload + in: body + name: payload + required: true + schema: + $ref: '#/definitions/handlers.SkillsUpsertRequest' + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.skillsOpResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Upload skills into container + tags: + - containerd /mcp/snapshots: get: parameters: diff --git a/internal/handlers/containerd.go b/internal/handlers/containerd.go index 35942085..070d9706 100644 --- a/internal/handlers/containerd.go +++ b/internal/handlers/containerd.go @@ -101,6 +101,9 @@ func (h *ContainerdHandler) Register(e *echo.Echo) { group.DELETE("/containers/:id", h.DeleteContainer) group.POST("/snapshots", h.CreateSnapshot) group.GET("/snapshots", h.ListSnapshots) + group.GET("/skills", h.ListSkills) + group.POST("/skills", h.UpsertSkills) + group.DELETE("/skills", h.DeleteSkills) group.POST("/fs/:id", h.HandleMCPFS) } @@ -155,6 +158,9 @@ func (h *ContainerdHandler) CreateContainer(c echo.Context) error { if err := os.MkdirAll(dataDir, 0o755); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + if err := os.MkdirAll(filepath.Join(dataDir, ".skills"), 0o755); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } specOpts := []oci.SpecOpts{ oci.WithMounts([]specs.Mount{{ @@ -241,6 +247,33 @@ func (h *ContainerdHandler) ensureTaskRunning(ctx context.Context, containerID s return err } +func (h *ContainerdHandler) userContainerID(ctx context.Context, userID string) (string, error) { + containers, err := h.service.ListContainersByLabel(ctx, mcp.UserLabelKey, userID) + if err != nil { + return "", err + } + if len(containers) == 0 { + return "", echo.NewHTTPError(http.StatusNotFound, "container not found") + } + infoCtx := ctx + if strings.TrimSpace(h.namespace) != "" { + infoCtx = namespaces.WithNamespace(ctx, h.namespace) + } + bestID := "" + var bestUpdated time.Time + for _, container := range containers { + info, err := container.Info(infoCtx) + if err != nil { + return "", err + } + if bestID == "" || info.UpdatedAt.After(bestUpdated) { + bestID = info.ID + bestUpdated = info.UpdatedAt + } + } + return bestID, nil +} + // ListContainers godoc // @Summary List containers // @Tags containerd diff --git a/internal/handlers/fs.go b/internal/handlers/fs.go index c0de6f7c..b1c70cae 100644 --- a/internal/handlers/fs.go +++ b/internal/handlers/fs.go @@ -23,25 +23,6 @@ import ( mcptools "github.com/memohai/memoh/internal/mcp" ) -type mcpJSONRPCRequest struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` -} - -type mcpJSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - ID json.RawMessage `json:"id,omitempty"` - Result any `json:"result,omitempty"` - Error *mcpJSONRPCError `json:"error,omitempty"` -} - -type mcpJSONRPCError struct { - Code int `json:"code"` - Message string `json:"message"` -} - // HandleMCPFS godoc // @Summary MCP filesystem tools (JSON-RPC) // @Description Forwards MCP JSON-RPC requests to the MCP server inside the container. @@ -72,15 +53,15 @@ func (h *ContainerdHandler) HandleMCPFS(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "container id is required") } - var req mcpJSONRPCRequest + var req mcptools.JSONRPCRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if req.JSONRPC != "" && req.JSONRPC != "2.0" { - return c.JSON(http.StatusOK, mcpJSONRPCResponse{ + return c.JSON(http.StatusOK, mcptools.JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, - Error: &mcpJSONRPCError{Code: -32600, Message: "invalid jsonrpc version"}, + Error: &mcptools.JSONRPCError{Code: -32600, Message: "invalid jsonrpc version"}, }) } @@ -109,10 +90,10 @@ func (h *ContainerdHandler) HandleMCPFS(c echo.Context) error { } return c.JSON(http.StatusOK, payload) default: - return c.JSON(http.StatusOK, mcpJSONRPCResponse{ + return c.JSON(http.StatusOK, mcptools.JSONRPCResponse{ JSONRPC: "2.0", ID: req.ID, - Error: &mcpJSONRPCError{Code: -32601, Message: "method not found"}, + Error: &mcptools.JSONRPCError{Code: -32601, Message: "method not found"}, }) } } @@ -155,7 +136,7 @@ func (h *ContainerdHandler) requireUserID(c echo.Context) (string, error) { return userID, nil } -func (h *ContainerdHandler) callMCPServer(ctx context.Context, containerID string, req mcpJSONRPCRequest) (map[string]any, error) { +func (h *ContainerdHandler) callMCPServer(ctx context.Context, containerID string, req mcptools.JSONRPCRequest) (map[string]any, error) { session, err := h.getMCPSession(ctx, containerID) if err != nil { return nil, err @@ -171,7 +152,7 @@ type mcpSession struct { initOnce sync.Once writeMu sync.Mutex pendingMu sync.Mutex - pending map[string]chan mcpJSONRPCResponse + pending map[string]chan mcptools.JSONRPCResponse closed chan struct{} closeOnce sync.Once closeErr error @@ -225,7 +206,7 @@ func (h *ContainerdHandler) startContainerdMCPSession(ctx context.Context, conta stdin: execSession.Stdin, stdout: execSession.Stdout, stderr: execSession.Stderr, - pending: make(map[string]chan mcpJSONRPCResponse), + pending: make(map[string]chan mcptools.JSONRPCResponse), closed: make(chan struct{}), } @@ -290,7 +271,7 @@ func (h *ContainerdHandler) startLimaMCPSession(containerID string) (*mcpSession stdout: stdout, stderr: stderr, cmd: cmd, - pending: make(map[string]chan mcpJSONRPCResponse), + pending: make(map[string]chan mcptools.JSONRPCResponse), closed: make(chan struct{}), } @@ -314,7 +295,7 @@ func (s *mcpSession) closeWithError(err error) { for _, ch := range s.pending { close(ch) } - s.pending = map[string]chan mcpJSONRPCResponse{} + s.pending = map[string]chan mcptools.JSONRPCResponse{} s.pendingMu.Unlock() _ = s.stdin.Close() _ = s.stdout.Close() @@ -336,7 +317,7 @@ func (s *mcpSession) readLoop() { if line == "" { continue } - var resp mcpJSONRPCResponse + var resp mcptools.JSONRPCResponse if err := json.Unmarshal([]byte(line), &resp); err != nil { continue } @@ -362,7 +343,7 @@ func (s *mcpSession) readLoop() { } } -func (s *mcpSession) call(ctx context.Context, req mcpJSONRPCRequest) (map[string]any, error) { +func (s *mcpSession) call(ctx context.Context, req mcptools.JSONRPCRequest) (map[string]any, error) { payloads, targetID, err := buildMCPPayloads(req, &s.initOnce) if err != nil { return nil, err @@ -372,7 +353,7 @@ func (s *mcpSession) call(ctx context.Context, req mcpJSONRPCRequest) (map[strin return nil, fmt.Errorf("missing request id") } - respCh := make(chan mcpJSONRPCResponse, 1) + respCh := make(chan mcptools.JSONRPCResponse, 1) s.pendingMu.Lock() s.pending[target] = respCh s.pendingMu.Unlock() @@ -419,7 +400,7 @@ func (s *mcpSession) call(ctx context.Context, req mcpJSONRPCRequest) (map[strin } } -func buildMCPPayloads(req mcpJSONRPCRequest, initOnce *sync.Once) ([]string, json.RawMessage, error) { +func buildMCPPayloads(req mcptools.JSONRPCRequest, initOnce *sync.Once) ([]string, json.RawMessage, error) { if req.JSONRPC == "" { req.JSONRPC = "2.0" } diff --git a/internal/handlers/skills.go b/internal/handlers/skills.go new file mode 100644 index 00000000..8a50da2d --- /dev/null +++ b/internal/handlers/skills.go @@ -0,0 +1,342 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + + "github.com/memohai/memoh/internal/config" + mcptools "github.com/memohai/memoh/internal/mcp" +) + +type SkillItem struct { + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` +} + +type SkillsResponse struct { + Skills []SkillItem `json:"skills"` +} + +type SkillsUpsertRequest struct { + Skills []SkillItem `json:"skills"` +} + +type SkillsDeleteRequest struct { + Names []string `json:"names"` +} + +type skillsOpResponse struct { + OK bool `json:"ok"` +} + +// ListSkills godoc +// @Summary List skills from container +// @Tags containerd +// @Success 200 {object} SkillsResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /mcp/skills [get] +func (h *ContainerdHandler) ListSkills(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + ctx := c.Request().Context() + containerID, err := h.userContainerID(ctx, userID) + if err != nil { + return err + } + if err := h.ensureTaskRunning(ctx, containerID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if err := h.ensureSkillsDirHost(userID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + listPayload, err := h.callMCPTool(ctx, containerID, "fs.list", map[string]any{ + "path": ".skills", + "recursive": false, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + entries, err := extractListEntries(listPayload) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + skills := make([]SkillItem, 0, len(entries)) + for _, entry := range entries { + skillPath, name := skillPathForEntry(entry) + if skillPath == "" { + continue + } + content, err := h.readSkillFile(ctx, containerID, skillPath) + if err != nil { + continue + } + skills = append(skills, SkillItem{ + Name: name, + Description: skillDescription(content), + Content: content, + }) + } + + return c.JSON(http.StatusOK, SkillsResponse{Skills: skills}) +} + +// UpsertSkills godoc +// @Summary Upload skills into container +// @Tags containerd +// @Param payload body SkillsUpsertRequest true "Skills payload" +// @Success 200 {object} skillsOpResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /mcp/skills [post] +func (h *ContainerdHandler) UpsertSkills(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + var req SkillsUpsertRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if len(req.Skills) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "skills is required") + } + + ctx := c.Request().Context() + containerID, err := h.userContainerID(ctx, userID) + if err != nil { + return err + } + if err := h.ensureTaskRunning(ctx, containerID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + for _, skill := range req.Skills { + name := strings.TrimSpace(skill.Name) + if !isValidSkillName(name) { + return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name") + } + content := strings.TrimSpace(skill.Content) + if content == "" { + content = buildSkillContent(name, strings.TrimSpace(skill.Description)) + } + filePath := path.Join(".skills", name, "SKILL.md") + if _, err := h.callMCPTool(ctx, containerID, "fs.write", map[string]any{ + "path": filePath, + "content": content, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + return c.JSON(http.StatusOK, skillsOpResponse{OK: true}) +} + +// DeleteSkills godoc +// @Summary Delete skills from container +// @Tags containerd +// @Param payload body SkillsDeleteRequest true "Delete skills payload" +// @Success 200 {object} skillsOpResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /mcp/skills [delete] +func (h *ContainerdHandler) DeleteSkills(c echo.Context) error { + userID, err := h.requireUserID(c) + if err != nil { + return err + } + var req SkillsDeleteRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if len(req.Names) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "names is required") + } + + ctx := c.Request().Context() + containerID, err := h.userContainerID(ctx, userID) + if err != nil { + return err + } + if err := h.ensureTaskRunning(ctx, containerID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + for _, name := range req.Names { + skillName := strings.TrimSpace(name) + if !isValidSkillName(skillName) { + return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name") + } + deletePath := path.Join(".skills", skillName) + if _, err := h.callMCPTool(ctx, containerID, "fs.delete", map[string]any{ + "path": deletePath, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + return c.JSON(http.StatusOK, skillsOpResponse{OK: true}) +} + +func (h *ContainerdHandler) ensureSkillsDirHost(userID string) error { + dataRoot := strings.TrimSpace(h.cfg.DataRoot) + if dataRoot == "" { + dataRoot = config.DefaultDataRoot + } + skillsDir := path.Join(dataRoot, "users", userID, ".skills") + return os.MkdirAll(skillsDir, 0o755) +} + +func (h *ContainerdHandler) readSkillFile(ctx context.Context, containerID, filePath string) (string, error) { + payload, err := h.callMCPTool(ctx, containerID, "fs.read", map[string]any{ + "path": filePath, + }) + if err != nil { + return "", err + } + content, err := extractContentString(payload) + if err != nil { + return "", err + } + return content, nil +} + +func (h *ContainerdHandler) callMCPTool(ctx context.Context, containerID, toolName string, args map[string]any) (map[string]any, error) { + id := "skills-" + strconv.FormatInt(time.Now().UnixNano(), 10) + req, err := mcptools.NewToolCallRequest(id, toolName, args) + if err != nil { + return nil, err + } + payload, err := h.callMCPServer(ctx, containerID, req) + if err != nil { + return nil, err + } + if err := mcptools.PayloadError(payload); err != nil { + return nil, err + } + if err := mcptools.ResultError(payload); err != nil { + return nil, err + } + return payload, nil +} + +func extractListEntries(payload map[string]any) ([]skillEntry, error) { + result, err := mcptools.StructuredContent(payload) + if err != nil { + return nil, err + } + rawEntries, ok := result["entries"].([]any) + if !ok { + return nil, errors.New("invalid list response") + } + entries := make([]skillEntry, 0, len(rawEntries)) + for _, raw := range rawEntries { + entryMap, ok := raw.(map[string]any) + if !ok { + continue + } + entryPath, _ := entryMap["path"].(string) + if entryPath == "" { + continue + } + isDir, _ := entryMap["is_dir"].(bool) + entries = append(entries, skillEntry{Path: entryPath, IsDir: isDir}) + } + return entries, nil +} + +type skillEntry struct { + Path string + IsDir bool +} + +func extractContentString(payload map[string]any) (string, error) { + result, err := mcptools.StructuredContent(payload) + if err != nil { + return "", err + } + content, _ := result["content"].(string) + if content == "" { + return "", errors.New("empty content") + } + return content, nil +} + +func skillNameFromPath(rel string) string { + if rel == "" || rel == "SKILL.md" { + return "default" + } + parent := path.Dir(rel) + if parent == "." { + return "default" + } + return path.Base(parent) +} + +func skillPathForEntry(entry skillEntry) (string, string) { + rel := strings.TrimPrefix(entry.Path, ".skills/") + if rel == entry.Path { + rel = strings.TrimPrefix(entry.Path, "./.skills/") + } + if entry.IsDir { + name := path.Base(rel) + if name == "." || name == "" { + return "", "" + } + return path.Join(".skills", name, "SKILL.md"), name + } + if path.Base(rel) == "SKILL.md" { + return path.Join(".skills", "SKILL.md"), skillNameFromPath(rel) + } + return "", "" +} + +func skillDescription(content string) string { + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + return strings.TrimSpace(strings.TrimPrefix(line, "#")) + } + return line + } + return "" +} + +func buildSkillContent(name, description string) string { + if description == "" { + return "# " + name + } + return "# " + name + "\n\n" + description +} + +func isValidSkillName(name string) bool { + if name == "" { + return false + } + if strings.Contains(name, "..") { + return false + } + if strings.Contains(name, "/") || strings.Contains(name, "\\") { + return false + } + return true +} diff --git a/internal/mcp/service.go b/internal/mcp/service.go new file mode 100644 index 00000000..ba055c4b --- /dev/null +++ b/internal/mcp/service.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "encoding/json" + "errors" + "strconv" +) + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func NewToolCallRequest(id string, toolName string, args map[string]any) (JSONRPCRequest, error) { + params := map[string]any{ + "name": toolName, + "arguments": args, + } + rawParams, err := json.Marshal(params) + if err != nil { + return JSONRPCRequest{}, err + } + return JSONRPCRequest{ + JSONRPC: "2.0", + ID: RawStringID(id), + Method: "tools/call", + Params: rawParams, + }, nil +} + +func RawStringID(id string) json.RawMessage { + return json.RawMessage([]byte(strconv.Quote(id))) +} + +func PayloadError(payload map[string]any) error { + if payload == nil { + return errors.New("empty payload") + } + if errObj, ok := payload["error"].(map[string]any); ok { + if msg, ok := errObj["message"].(string); ok && msg != "" { + return errors.New(msg) + } + return errors.New("mcp error") + } + return nil +} + +func ResultError(payload map[string]any) error { + result, ok := payload["result"].(map[string]any) + if !ok { + return nil + } + if isErr, ok := result["isError"].(bool); ok && isErr { + msg := ContentText(result) + if msg == "" { + msg = "mcp tool error" + } + return errors.New(msg) + } + return nil +} + +func StructuredContent(payload map[string]any) (map[string]any, error) { + result, ok := payload["result"].(map[string]any) + if !ok { + return nil, errors.New("missing result") + } + if structured, ok := result["structuredContent"].(map[string]any); ok { + return structured, nil + } + if content := ContentText(result); content != "" { + var out map[string]any + if err := json.Unmarshal([]byte(content), &out); err == nil { + return out, nil + } + } + return nil, errors.New("missing structured content") +} + +func ContentText(result map[string]any) string { + rawContent, ok := result["content"].([]any) + if !ok || len(rawContent) == 0 { + return "" + } + first, ok := rawContent[0].(map[string]any) + if !ok { + return "" + } + text, _ := first["text"].(string) + return text +} diff --git a/internal/server/server.go b/internal/server/server.go index 761691d0..a40d7880 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,9 +29,6 @@ func NewServer(addr string, jwtSecret string, pingHandler *handlers.PingHandler, if path == "/ping" || path == "/api/swagger.json" || path == "/auth/login" { return true } - if strings.HasPrefix(path, "/mcp/") { - return false - } if strings.HasPrefix(path, "/api/docs") { return true } diff --git a/scripts/mcp_fs_test.py b/scripts/mcp_fs_test.py deleted file mode 100644 index 1fbf88aa..00000000 --- a/scripts/mcp_fs_test.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -import sys -import urllib.error -import urllib.request - - -def post_json(url, payload, headers=None): - data = json.dumps(payload).encode("utf-8") - final_headers = {"Content-Type": "application/json"} - if headers: - final_headers.update(headers) - req = urllib.request.Request(url, data=data, headers=final_headers, method="POST") - try: - with urllib.request.urlopen(req, timeout=30) as resp: - body = resp.read().decode("utf-8") - return resp.status, body - except urllib.error.HTTPError as e: - body = e.read().decode("utf-8") if e.fp else "" - return e.code, body - - -def rpc_call(base_url, container_id, rpc_id, method, params=None, headers=None): - url = f"{base_url.rstrip('/')}/mcp/fs/{container_id}" - payload = {"jsonrpc": "2.0", "id": rpc_id, "method": method} - if params is not None: - payload["params"] = params - status, body = post_json(url, payload, headers=headers) - return status, body - - -def print_response(title, status, body): - print(f"\n== {title} ==") - print(f"HTTP {status}") - if body: - print(body) - else: - print("") - - -def parse_json(body): - try: - return json.loads(body) - except Exception: - return None - - -def get_tool_result(payload): - if not isinstance(payload, dict): - return None - return payload.get("result") - - -def get_structured_content(result): - if not isinstance(result, dict): - return None - if "structuredContent" in result: - return result.get("structuredContent") - content = result.get("content") or [] - if not content: - return None - text = content[0].get("text") - if not text: - return None - return parse_json(text) - - -def expect_ok(title, status, body, failures): - if status != 200: - failures.append(f"{title}: expected HTTP 200, got {status}") - return None - payload = parse_json(body) - result = get_tool_result(payload) - if not isinstance(result, dict) or result.get("isError"): - failures.append(f"{title}: expected isError=false") - return result - return result - - -def expect_error(title, status, body, failures): - if status != 200: - failures.append(f"{title}: expected HTTP 200, got {status}") - return None - payload = parse_json(body) - result = get_tool_result(payload) - if not isinstance(result, dict) or not result.get("isError"): - failures.append(f"{title}: expected isError=true") - return result - return result - - -def main(): - parser = argparse.ArgumentParser(description="Test MCP fs JSON-RPC endpoint") - parser.add_argument( - "--base-url", - default="http://127.0.0.1:8080", - help="API base URL (default: http://127.0.0.1:8080)", - ) - parser.add_argument( - "--container-id", - default="test-create-1769798787", - help="Container ID to target", - ) - parser.add_argument( - "--path", - default="notes.txt", - help="Relative path used in examples", - ) - parser.add_argument( - "--token", - default="", - help="Bearer token (or set MCP_TOKEN env var)", - ) - args = parser.parse_args() - token = args.token or os.getenv("MCP_TOKEN", "") - headers = {"Authorization": f"Bearer {token}"} if token else None - - failures = [] - rpc_id = 1 - - def call(title, method, params=None): - nonlocal rpc_id - status, body = rpc_call( - args.base_url, - args.container_id, - rpc_id, - method, - params=params, - headers=headers, - ) - print_response(title, status, body) - rpc_id += 1 - return status, body - - status, body = call("tools/list", "tools/list") - expect_ok("tools/list", status, body, failures) - - files = [ - ("alpha.txt", "alpha"), - ("dir1/beta.txt", "beta"), - ("dir1/dir2/gamma.txt", "gamma"), - ] - - for path, content in files: - status, body = call( - f"fs.write {path}", - "tools/call", - {"name": "fs.write", "arguments": {"path": path, "content": content}}, - ) - expect_ok(f"fs.write {path}", status, body, failures) - - for path, content in files: - status, body = call( - f"fs.read {path}", - "tools/call", - {"name": "fs.read", "arguments": {"path": path}}, - ) - result = expect_ok(f"fs.read {path}", status, body, failures) - sc = get_structured_content(result) if result else None - if not sc or sc.get("content") != content: - failures.append(f"fs.read {path}: content mismatch") - - status, body = call( - "fs.list (non-recursive)", - "tools/call", - {"name": "fs.list", "arguments": {"path": "", "recursive": False}}, - ) - expect_ok("fs.list (non-recursive)", status, body, failures) - - status, body = call( - "fs.list (recursive)", - "tools/call", - {"name": "fs.list", "arguments": {"path": "", "recursive": True}}, - ) - result = expect_ok("fs.list (recursive)", status, body, failures) - sc = get_structured_content(result) if result else None - if sc and "entries" in sc: - listed = {e.get("path") for e in sc.get("entries", [])} - for path, _ in files: - if path not in listed: - failures.append(f"fs.list (recursive): missing {path}") - - for path, _ in files: - status, body = call( - f"fs.stat {path}", - "tools/call", - {"name": "fs.stat", "arguments": {"path": path}}, - ) - expect_ok(f"fs.stat {path}", status, body, failures) - - patch = "@@ -1,5 +1,5 @@\n-alpha\n+alpha-patched" - status, body = call( - "fs.apply_patch alpha.txt", - "tools/call", - {"name": "fs.apply_patch", "arguments": {"path": "alpha.txt", "patch": patch}}, - ) - expect_ok("fs.apply_patch alpha.txt", status, body, failures) - - status, body = call( - "fs.read alpha.txt (after patch)", - "tools/call", - {"name": "fs.read", "arguments": {"path": "alpha.txt"}}, - ) - result = expect_ok("fs.read alpha.txt (after patch)", status, body, failures) - sc = get_structured_content(result) if result else None - if not sc or sc.get("content") != "alpha-patched": - failures.append("fs.read alpha.txt (after patch): content mismatch") - - status, body = call( - "fs.delete dir1", - "tools/call", - {"name": "fs.delete", "arguments": {"path": "dir1"}}, - ) - expect_ok("fs.delete dir1", status, body, failures) - - status, body = call( - "fs.read dir1/beta.txt (after delete)", - "tools/call", - {"name": "fs.read", "arguments": {"path": "dir1/beta.txt"}}, - ) - expect_error("fs.read dir1/beta.txt (after delete)", status, body, failures) - - status, body = call( - "fs.read ../escape (invalid path)", - "tools/call", - {"name": "fs.read", "arguments": {"path": "../escape"}}, - ) - expect_error("fs.read ../escape (invalid path)", status, body, failures) - - if failures: - print("\n== SUMMARY ==") - for item in failures: - print(f"- FAIL: {item}") - return 1 - - print("\n== SUMMARY ==\nAll checks passed.") - return 0 - - -if __name__ == "__main__": - sys.exit(main())