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
8 changes: 8 additions & 0 deletions .changeset/mcp-registry-skill-finder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@perstack/create-expert-skill": patch
"create-expert": patch
---

feat: add MCP registry search tools and skill-finder expert

Add `searchMcpRegistry` and `getMcpServerDetail` tools to `@perstack/create-expert-skill` that search the official MCP registry for MCP servers matching expert skill requirements. Add `@create-expert/skill-finder` expert that uses these tools to investigate registry entries and produce skill-report.md with ready-to-use TOML configuration snippets. Update coordinator, planner, and definition-writer instructions to integrate skill findings into generated expert definitions.
8 changes: 8 additions & 0 deletions apps/create-expert-skill/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,12 @@ export {
SKILL_NAME,
SKILL_VERSION,
} from "./server.js"
export {
getMcpServerDetail,
registerGetMcpServerDetail,
} from "./tools/get-mcp-server-detail.js"
export { registerRunExpert, runExpert } from "./tools/run-expert.js"
export {
registerSearchMcpRegistry,
searchMcpRegistry,
} from "./tools/search-mcp-registry.js"
237 changes: 237 additions & 0 deletions apps/create-expert-skill/src/lib/mcp-registry-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import { clearCache, fetchAllServers, fetchServerDetail } from "./mcp-registry-client.js"

const originalFetch = globalThis.fetch

afterEach(() => {
globalThis.fetch = originalFetch
clearCache()
})

describe("fetchAllServers", () => {
it("fetches servers from a single page", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
servers: [
{
name: "server-a",
description: "A server",
version_detail: { version: "1.0.0", status: "active" },
},
{
name: "server-b",
description: "B server",
version_detail: { version: "2.0.0", status: "active" },
},
],
}),
{ status: 200 },
),
),
) as typeof fetch

const servers = await fetchAllServers()
expect(servers).toHaveLength(2)
expect(servers[0].name).toBe("server-a")
expect(servers[1].name).toBe("server-b")
})

it("paginates through multiple pages", async () => {
let callCount = 0
globalThis.fetch = mock(() => {
callCount++
if (callCount === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
servers: [
{
name: "page1-server",
description: "first",
version_detail: { version: "1.0.0", status: "active" },
},
],
next_cursor: "cursor-abc",
}),
{ status: 200 },
),
)
}
return Promise.resolve(
new Response(
JSON.stringify({
servers: [
{
name: "page2-server",
description: "second",
version_detail: { version: "1.0.0", status: "active" },
},
],
}),
{ status: 200 },
),
)
}) as typeof fetch

const servers = await fetchAllServers()
expect(servers).toHaveLength(2)
expect(servers[0].name).toBe("page1-server")
expect(servers[1].name).toBe("page2-server")
expect(callCount).toBe(2)
})

it("includes cursor query parameter for pagination", async () => {
const urls: string[] = []
let callCount = 0
globalThis.fetch = mock((input: string | URL | Request) => {
urls.push(typeof input === "string" ? input : input.toString())
callCount++
if (callCount === 1) {
return Promise.resolve(
new Response(
JSON.stringify({
servers: [
{
name: "s1",
description: "",
version_detail: { version: "1.0.0", status: "active" },
},
],
next_cursor: "my-cursor",
}),
{ status: 200 },
),
)
}
return Promise.resolve(new Response(JSON.stringify({ servers: [] }), { status: 200 }))
}) as typeof fetch

await fetchAllServers()

expect(urls[0]).not.toContain("cursor=")
expect(urls[1]).toContain("cursor=my-cursor")
})

it("returns cached data on second call", async () => {
let callCount = 0
globalThis.fetch = mock(() => {
callCount++
return Promise.resolve(
new Response(
JSON.stringify({
servers: [
{
name: "cached",
description: "",
version_detail: { version: "1.0.0", status: "active" },
},
],
}),
{ status: 200 },
),
)
}) as typeof fetch

const first = await fetchAllServers()
const second = await fetchAllServers()

expect(first).toEqual(second)
expect(callCount).toBe(1)
})

it("throws on HTTP error", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response("", { status: 500, statusText: "Internal Server Error" })),
) as typeof fetch

await expect(fetchAllServers()).rejects.toThrow("Registry API error: 500")
})

it("throws on network error", async () => {
globalThis.fetch = mock(() => Promise.reject(new Error("Network failure"))) as typeof fetch

await expect(fetchAllServers()).rejects.toThrow("Network failure")
})
})

describe("fetchServerDetail", () => {
it("fetches server detail", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
name: "my/server",
description: "Test server",
version: "1.0.0",
packages: [],
remotes: [],
status: "active",
}),
{ status: 200 },
),
),
) as typeof fetch

const detail = await fetchServerDetail("my/server")
expect(detail.name).toBe("my/server")
expect(detail.description).toBe("Test server")
})

it("encodes server name with slashes", async () => {
let calledUrl = ""
globalThis.fetch = mock((input: string | URL | Request) => {
calledUrl = typeof input === "string" ? input : input.toString()
return Promise.resolve(
new Response(
JSON.stringify({
name: "org/repo",
description: "",
version: "1.0.0",
packages: [],
remotes: [],
status: "active",
}),
{ status: 200 },
),
)
}) as typeof fetch

await fetchServerDetail("org/repo", "latest")
expect(calledUrl).toContain("org%2Frepo")
})

it("caches server detail", async () => {
let callCount = 0
globalThis.fetch = mock(() => {
callCount++
return Promise.resolve(
new Response(
JSON.stringify({
name: "test",
description: "",
version: "1.0.0",
packages: [],
remotes: [],
status: "active",
}),
{ status: 200 },
),
)
}) as typeof fetch

await fetchServerDetail("test", "latest")
await fetchServerDetail("test", "latest")

expect(callCount).toBe(1)
})

it("throws on 404", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response("", { status: 404, statusText: "Not Found" })),
) as typeof fetch

await expect(fetchServerDetail("nonexistent")).rejects.toThrow("Registry API error: 404")
})
})
Loading