Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ $(LOCALBIN):
mkdir -p "$(LOCALBIN)"

GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
GOLANGCI_LINT_VERSION ?= v2.5.0
GOLANGCI_LINT_VERSION ?= v2.7.2
GO_JUNIT_REPORT = $(LOCALBIN)/go-junit-report
GO_JUNIT_REPORT_VERSION ?= v1.0.0
GOPLS = $(LOCALBIN)/gopls
Expand Down
16 changes: 16 additions & 0 deletions designs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@

## Implementation Tasks

- [x] 2026-04-24: Added MCP skill publishing tool for registry creation from markdown (TDD-first):
- Added new MCP tool `skill_publish` in `internal/api/mcp/skill_publish.go` with scope-auth enforcement and `skills:write` permission.
- Made create+publish atomic for `skill_publish` by creating skills with initial `published` status in a single create operation (no follow-up status update step).
- Updated `internal/skills/store.go` API comments to document that `Store.Create` defaults to draft creation but may create published skills when `CreateInput.Status`/`PublishedAt` are set.
- Tool accepts either full markdown `content` (with optional `SKILL.md` frontmatter) or explicit `body` + metadata and derives missing slug/name fields.
- Tightened frontmatter parsing so invalid inline `agent_types` lists now return an explicit error instead of silently falling back to defaults.
- Improved supplementary file validation diagnostics to include the failing item index in `skill_publish` file-parse errors.
- Normalized MCP array schema declaration for `agent_types` to use `WithStringItems()` for consistency with other tool definitions and strict clients.
- Normalized `source_name` path handling for slug derivation to be separator-stable across platforms (supports both `/` and `\` inputs consistently).
- Extended tool input to support supplementary `files` (scripts/references) and persist them through the existing skills store validation/writes.
- Tool creates the skill in the target scope and marks it `published` so it is immediately discoverable by `skill_search` and installable via `skill_install`/CLI sync.
- Added parser/unit coverage in `internal/api/mcp/skill_publish_test.go`, integration coverage in `internal/api/mcp/mcp_integration_test.go`, and handler validation coverage in `internal/api/mcp/handlers_unit_test.go`.
- Extended MCP scope-auth inventory and integration coverage for `skill_publish`:
- `internal/api/mcp/scopeauth_inventory_test.go`
- `internal/api/mcp/scope_authz_integration_test.go`

- [x] 2026-04-24: Fixed social login duplicate principal creation when email slug already exists (TDD-first):
- Added integration regression coverage in `internal/social/identity_test.go`:
- `TestIdentityStore_FindOrCreate_ExistingEmailSlug_LinksPrincipal`
Expand Down
32 changes: 32 additions & 0 deletions internal/api/mcp/handlers_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,35 @@ func TestHandleSkillInvoke_NilPool_ReturnsToolError(t *testing.T) {
}, s.handleSkillInvoke)
assertToolError(t, result)
}

// ── handleSkillPublish ────────────────────────────────────────────────────────

func TestHandleSkillPublish_MissingScope_ReturnsToolError(t *testing.T) {
s := &Server{}
result := callTool(t, map[string]any{
"content": "skill body",
}, s.handleSkillPublish)
assertToolError(t, result)
}

func TestHandleSkillPublish_MissingContentAndBody_ReturnsToolError(t *testing.T) {
s := &Server{}
result := callTool(t, map[string]any{
"scope": "project:acme/api",
}, s.handleSkillPublish)
assertToolError(t, result)
}

func TestHandleSkillPublish_ServerNotConfigured_PrecedesFilesValidation(t *testing.T) {
s := &Server{}
result := callTool(t, map[string]any{
"scope": "project:acme/api",
"body": "skill body",
"files": "not-an-array",
}, s.handleSkillPublish)
assertToolError(t, result)
msg := strings.ToLower(toolErrorText(t, result))
if !strings.Contains(msg, "server not configured") {
t.Fatalf("expected server-not-configured error, got %q", msg)
}
}
87 changes: 87 additions & 0 deletions internal/api/mcp/mcp_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package mcp_test
import (
"context"
"encoding/json"
"strings"
"testing"

"github.com/google/uuid"
Expand Down Expand Up @@ -187,6 +188,92 @@ func TestMCP_Publish_Endorse_AutoPublish(t *testing.T) {
}
}

func TestMCP_SkillPublish_WithFiles(t *testing.T) {
ctx := context.Background()
pool := testhelper.NewTestPool(t)
svc := testhelper.NewMockEmbeddingService()
cfg := &config.Config{}

principal := testhelper.CreateTestPrincipal(t, pool, "user", "mcp-skill-publish-user")
scope := testhelper.CreateTestScope(t, pool, "project", "mcp-skill-publish-project", nil, principal.ID)
testhelper.CreateTestEmbeddingModel(t, pool)

srv := mcpapi.NewServer(pool, svc, cfg)
mcpSrv := srv.MCPServer()

scopeStr := "project:" + scope.ExternalID
ctx = withAuthContext(ctx, pool, principal.ID, scope.ID)

tool := mcpSrv.GetTool("skill_publish")
if tool == nil {
t.Fatal("skill_publish tool not registered")
}

req := mcpgo.CallToolRequest{}
req.Params.Name = "skill_publish"
req.Params.Arguments = map[string]any{
"scope": scopeStr,
"source_name": "tox-verifier.md",
"content": strings.Join([]string{
"---",
"name: Tox Verifier",
"description: Verify tox output and summarize failures",
"---",
"",
"Run tox and summarize failures.",
}, "\n"),
"files": []any{
map[string]any{
"path": "scripts/run.sh",
"content": "#!/bin/sh\ntox -e py\n",
"executable": true,
},
map[string]any{
"path": "references/tox-format.md",
"content": "Reference output format",
},
},
}

result, err := tool.Handler(ctx, req)
if err != nil {
t.Fatalf("skill_publish failed: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("skill_publish returned error result: %+v", result)
}
if len(result.Content) == 0 {
t.Fatal("expected non-empty result content")
}
text, ok := result.Content[0].(mcpgo.TextContent)
if !ok {
t.Fatalf("expected TextContent, got %T", result.Content[0])
}
var out struct {
SkillID string `json:"skill_id"`
Slug string `json:"slug"`
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(text.Text), &out); err != nil {
t.Fatalf("skill_publish output is not JSON: %v", err)
}
if out.Status != "published" {
t.Fatalf("status = %q, want published", out.Status)
}
skillID, err := uuid.Parse(out.SkillID)
if err != nil {
t.Fatalf("invalid skill_id %q: %v", out.SkillID, err)
}

files, err := compat.ListSkillFiles(ctx, pool, skillID)
if err != nil {
t.Fatalf("ListSkillFiles: %v", err)
}
if len(files) != 2 {
t.Fatalf("len(files) = %d, want 2", len(files))
}
}

func withAuthContext(ctx context.Context, pool *pgxpool.Pool, principalID, scopeID uuid.UUID) context.Context {
return withAuthContextPerms(ctx, pool, principalID, scopeID, []string{"read", "write", "edit", "delete"})
}
Expand Down
17 changes: 17 additions & 0 deletions internal/api/mcp/scope_authz_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,23 @@ func TestMCP_ScopeAuthz_ScopeTakingTools(t *testing.T) {
}
},
},
{
name: "skill_publish",
argsFor: func(scope string) map[string]any {
return map[string]any{
"scope": scope,
"source_name": "tox-verifier.md",
"content": strings.Join([]string{
"---",
"name: Tox Verifier",
"description: Verify tox logs and summarize failures",
"---",
"",
"Run tox and summarize test failures.",
}, "\n"),
}
},
},
}

for _, tc := range cases {
Expand Down
1 change: 1 addition & 0 deletions internal/api/mcp/scopeauth_inventory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var mcpScopeToolInventory = []mcpScopeToolInventoryItem{
{File: "synthesize.go", Handler: "handleSynthesizeTopic", Tool: "synthesize_topic", Operation: "synthesize digest"},
{File: "skill_install.go", Handler: "handleSkillInstall", Tool: "skill_install", Operation: "install skill"},
{File: "skill_invoke.go", Handler: "handleSkillInvoke", Tool: "skill_invoke", Operation: "invoke skill"},
{File: "skill_publish.go", Handler: "handleSkillPublish", Tool: "skill_publish", Operation: "publish skill"},
}

func TestScopeTakingHandlersCallAuthorizeRequestedScope(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions internal/api/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func (s *Server) registerTools() {
s.registerCollect()
s.registerKnowledgeDetail()
s.registerSkillSearch()
s.registerSkillPublish()
s.registerSkillInstall()
s.registerSkillInvoke()
s.registerListScopes()
Expand Down
Loading
Loading