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
6 changes: 6 additions & 0 deletions .changeset/skill-manager-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@perstack/runtime": patch
"@perstack/installer": patch
---

Introduce @perstack/skill-manager package and migrate runtime to use SkillManager
2 changes: 2 additions & 0 deletions apps/base/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { errorToolResult, successToolResult } from "./lib/tool-result.js"
export {
BASE_SKILL_NAME,
BASE_SKILL_VERSION,
type CreateBaseServerOptions,
createBaseServer,
registerAllTools,
} from "./server.js"
Expand All @@ -12,5 +13,6 @@ export * from "./tools/exec.js"
export * from "./tools/read-image-file.js"
export * from "./tools/read-pdf-file.js"
export * from "./tools/read-text-file.js"
export * from "./tools/skill-management.js"
export * from "./tools/todo.js"
export * from "./tools/write-text-file.js"
11 changes: 10 additions & 1 deletion apps/base/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { registerExec } from "./tools/exec.js"
import { registerReadImageFile } from "./tools/read-image-file.js"
import { registerReadPdfFile } from "./tools/read-pdf-file.js"
import { registerReadTextFile } from "./tools/read-text-file.js"
import type { SkillManagementCallbacks } from "./tools/skill-management.js"
import { registerSkillManagementTools } from "./tools/skill-management.js"
import { registerClearTodo, registerTodo } from "./tools/todo.js"
import { registerWriteTextFile } from "./tools/write-text-file.js"

Expand All @@ -31,11 +33,15 @@ export function registerAllTools(server: McpServer): void {
registerEditTextFile(server)
}

export interface CreateBaseServerOptions {
skillManagement?: SkillManagementCallbacks
}

/**
* Create a base skill MCP server with all tools registered.
* Used by the runtime for in-process execution via InMemoryTransport.
*/
export function createBaseServer(): McpServer {
export function createBaseServer(options?: CreateBaseServerOptions): McpServer {
const server = new McpServer(
{
name: BASE_SKILL_NAME,
Expand All @@ -48,5 +54,8 @@ export function createBaseServer(): McpServer {
},
)
registerAllTools(server)
if (options?.skillManagement) {
registerSkillManagementTools(server, options.skillManagement)
}
return server
}
202 changes: 202 additions & 0 deletions apps/base/src/tools/skill-management.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from "vitest"
import type { SkillManagementCallbacks } from "./skill-management.js"
import {
registerAddDelegate,
registerAddSkill,
registerRemoveDelegate,
registerRemoveSkill,
} from "./skill-management.js"

function createMockCallbacks(): SkillManagementCallbacks {
return {
addSkill: vi.fn().mockResolvedValue({ tools: ["toolA", "toolB"] }),
removeSkill: vi.fn().mockResolvedValue(undefined),
addDelegate: vi.fn().mockResolvedValue({ delegateToolName: "delegate-tool" }),
removeDelegate: vi.fn().mockResolvedValue(undefined),
}
}

function createMockServer() {
return { registerTool: vi.fn() }
}

function getHandler(mockServer: { registerTool: ReturnType<typeof vi.fn> }) {
return mockServer.registerTool.mock.calls[0][2]
}

describe("skill-management tools", () => {
describe("addSkill", () => {
it("registers tool with correct metadata", () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerAddSkill(server as never, callbacks)
expect(server.registerTool).toHaveBeenCalledWith(
"addSkill",
expect.objectContaining({ title: "Add skill" }),
expect.any(Function),
)
})

it("calls callback with correct input and returns tool list", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerAddSkill(server as never, callbacks)
const handler = getHandler(server)
const input = {
name: "my-skill",
type: "mcpStdioSkill" as const,
command: "npx",
packageName: "@my/pkg",
}
const result = await handler(input)
expect(callbacks.addSkill).toHaveBeenCalledWith(input)
expect(result).toStrictEqual({
content: [{ type: "text", text: JSON.stringify({ tools: ["toolA", "toolB"] }) }],
})
})

it("returns errorToolResult when callback throws", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
;(callbacks.addSkill as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("skill already exists"),
)
registerAddSkill(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ name: "dup", type: "mcpStdioSkill" })
expect(result).toStrictEqual({
content: [
{
type: "text",
text: JSON.stringify({ error: "Error", message: "skill already exists" }),
},
],
})
})
})

describe("removeSkill", () => {
it("registers tool with correct metadata", () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerRemoveSkill(server as never, callbacks)
expect(server.registerTool).toHaveBeenCalledWith(
"removeSkill",
expect.objectContaining({ title: "Remove skill" }),
expect.any(Function),
)
})

it("calls callback with skill name", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerRemoveSkill(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ skillName: "my-skill" })
expect(callbacks.removeSkill).toHaveBeenCalledWith("my-skill")
expect(result).toStrictEqual({
content: [{ type: "text", text: JSON.stringify({ removed: "my-skill" }) }],
})
})

it("returns errorToolResult when callback throws", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
;(callbacks.removeSkill as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"))
registerRemoveSkill(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ skillName: "missing" })
expect(result).toStrictEqual({
content: [{ type: "text", text: JSON.stringify({ error: "Error", message: "not found" }) }],
})
})
})

describe("addDelegate", () => {
it("registers tool with correct metadata", () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerAddDelegate(server as never, callbacks)
expect(server.registerTool).toHaveBeenCalledWith(
"addDelegate",
expect.objectContaining({ title: "Add delegate" }),
expect.any(Function),
)
})

it("calls callback with expert key and returns delegate tool name", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerAddDelegate(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ expertKey: "my-expert" })
expect(callbacks.addDelegate).toHaveBeenCalledWith("my-expert")
expect(result).toStrictEqual({
content: [{ type: "text", text: JSON.stringify({ delegateToolName: "delegate-tool" }) }],
})
})

it("returns errorToolResult when callback throws", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
;(callbacks.addDelegate as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("expert not found"),
)
registerAddDelegate(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ expertKey: "missing" })
expect(result).toStrictEqual({
content: [
{
type: "text",
text: JSON.stringify({ error: "Error", message: "expert not found" }),
},
],
})
})
})

describe("removeDelegate", () => {
it("registers tool with correct metadata", () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerRemoveDelegate(server as never, callbacks)
expect(server.registerTool).toHaveBeenCalledWith(
"removeDelegate",
expect.objectContaining({ title: "Remove delegate" }),
expect.any(Function),
)
})

it("calls callback with expert name", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
registerRemoveDelegate(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ expertName: "my-expert" })
expect(callbacks.removeDelegate).toHaveBeenCalledWith("my-expert")
expect(result).toStrictEqual({
content: [{ type: "text", text: JSON.stringify({ removed: "my-expert" }) }],
})
})

it("returns errorToolResult when callback throws", async () => {
const server = createMockServer()
const callbacks = createMockCallbacks()
;(callbacks.removeDelegate as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("delegate not found"),
)
registerRemoveDelegate(server as never, callbacks)
const handler = getHandler(server)
const result = await handler({ expertName: "missing" })
expect(result).toStrictEqual({
content: [
{
type: "text",
text: JSON.stringify({ error: "Error", message: "delegate not found" }),
},
],
})
})
})
})
Loading