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
5 changes: 5 additions & 0 deletions .changeset/shaggy-squids-clap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@webiny/stdlib": patch
---

feat: add MCP server for AI agent skill discovery (`stdlib-mcp serve` / `stdlib-mcp configure`)
8 changes: 4 additions & 4 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ jobs:
name: Dependency audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -29,5 +29,5 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
34 changes: 17 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ jobs:
name: Lockfile immutability
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -24,7 +24,7 @@ jobs:
name: No committed dist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Verify dist is not tracked in git
run: |
tracked=$(git ls-files dist/)
Expand All @@ -40,8 +40,8 @@ jobs:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -52,8 +52,8 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -64,8 +64,8 @@ jobs:
name: Import check (adio)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -76,8 +76,8 @@ jobs:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -89,8 +89,8 @@ jobs:
needs: [lockfile, format, lint, imports, typecheck]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -102,8 +102,8 @@ jobs:
needs: [build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand All @@ -115,8 +115,8 @@ jobs:
needs: [build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
name: Conventional commit title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
cache: yarn
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ lerna.json
.stormTests
./nextjs

.superpowers
/.local
CLAUDE.local.md
.claude/*.local.*
196 changes: 196 additions & 0 deletions __tests__/mcp/Configure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
writeMcpConfig,
writeHintFile,
stdlibHintBlock
} from "../../src/mcp/features/Configure/agents/shared.js";

describe("writeMcpConfig", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-mcp-config-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it("should create new config file with stdlib entry", () => {
writeMcpConfig({ cwd: tmpDir, configFile: ".mcp.json", configKey: "mcpServers" });

const content = JSON.parse(readFileSync(join(tmpDir, ".mcp.json"), "utf-8"));
expect(content.mcpServers.stdlib).toBeDefined();
expect(content.mcpServers.stdlib.command).toBe("npx");
expect(content.mcpServers.stdlib.args).toContain("stdlib-mcp");
});

it("should preserve existing entries when patching", () => {
const configPath = join(tmpDir, ".mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: { other: { command: "other-cmd" } }
})
);

writeMcpConfig({ cwd: tmpDir, configFile: ".mcp.json", configKey: "mcpServers" });

const content = JSON.parse(readFileSync(configPath, "utf-8"));
expect(content.mcpServers.other).toBeDefined();
expect(content.mcpServers.stdlib).toBeDefined();
});

it("should skip if stdlib entry already exists", () => {
const configPath = join(tmpDir, ".mcp.json");
const original = { mcpServers: { stdlib: { command: "custom" } } };
writeFileSync(configPath, JSON.stringify(original));

writeMcpConfig({ cwd: tmpDir, configFile: ".mcp.json", configKey: "mcpServers" });

const content = JSON.parse(readFileSync(configPath, "utf-8"));
expect(content.mcpServers.stdlib.command).toBe("custom");
});

it("should create nested directories", () => {
writeMcpConfig({
cwd: tmpDir,
configFile: ".cursor/mcp.json",
configKey: "mcpServers"
});

expect(existsSync(join(tmpDir, ".cursor", "mcp.json"))).toBe(true);
});

it("should use servers key for copilot", () => {
writeMcpConfig({ cwd: tmpDir, configFile: ".vscode/mcp.json", configKey: "servers" });

const content = JSON.parse(readFileSync(join(tmpDir, ".vscode", "mcp.json"), "utf-8"));
expect(content.servers.stdlib).toBeDefined();
});
});

describe("writeHintFile", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-mcp-hint-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

it("should create new hint file", () => {
writeHintFile({
cwd: tmpDir,
hintFile: "CLAUDE.md",
content: stdlibHintBlock()
});

const text = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
expect(text).toContain("list_stdlib_skills");
});

it("should append to existing file with blank line separator", () => {
const hintPath = join(tmpDir, "CLAUDE.md");
writeFileSync(hintPath, "# Existing content");

writeHintFile({
cwd: tmpDir,
hintFile: "CLAUDE.md",
content: stdlibHintBlock()
});

const text = readFileSync(hintPath, "utf-8");
expect(text).toContain("# Existing content");
expect(text).toContain("list_stdlib_skills");
expect(text).toContain("\n\n## @webiny/stdlib MCP");
});

it("should skip if marker already present", () => {
const hintPath = join(tmpDir, "CLAUDE.md");
writeFileSync(hintPath, "Already has list_stdlib_skills marker.");

writeHintFile({
cwd: tmpDir,
hintFile: "CLAUDE.md",
content: stdlibHintBlock()
});

const text = readFileSync(hintPath, "utf-8");
expect(text).toBe("Already has list_stdlib_skills marker.");
});

it("should create nested directories", () => {
writeHintFile({
cwd: tmpDir,
hintFile: ".cursor/rules/stdlib.mdc",
content: stdlibHintBlock()
});

expect(existsSync(join(tmpDir, ".cursor", "rules", "stdlib.mdc"))).toBe(true);
});
});

describe("agent adapters", () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `wby-mcp-agent-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

const agents = [
{ module: "claude", configFile: ".mcp.json", hintFile: "CLAUDE.md" },
{ module: "cursor", configFile: ".cursor/mcp.json", hintFile: ".cursor/rules/stdlib.mdc" },
{
module: "copilot",
configFile: ".vscode/mcp.json",
hintFile: ".github/copilot-instructions.md"
},
{
module: "windsurf",
configFile: ".windsurf/mcp.json",
hintFile: ".windsurf/rules/stdlib.md"
},
{ module: "kiro", configFile: ".kiro/settings/mcp.json", hintFile: "AGENTS.md" },
{ module: "opencode", configFile: "opencode.json", hintFile: "AGENTS.md" }
];

for (const { module: mod, configFile, hintFile } of agents) {
it(`should configure ${mod} agent`, async () => {
const agent = await import(
/* @vite-ignore */ `../../src/mcp/features/Configure/agents/${mod}.js`
);
await agent.default.init({ cwd: tmpDir });

expect(existsSync(join(tmpDir, configFile))).toBe(true);
expect(existsSync(join(tmpDir, hintFile))).toBe(true);

const config = JSON.parse(readFileSync(join(tmpDir, configFile), "utf-8"));
const hint = readFileSync(join(tmpDir, hintFile), "utf-8");

expect(JSON.stringify(config)).toContain("stdlib");
expect(hint).toContain("list_stdlib_skills");
});
}

it("should configure cline agent (no hint file)", async () => {
const agent = await import(
/* @vite-ignore */ "../../src/mcp/features/Configure/agents/cline.js"
);
await agent.default.init({ cwd: tmpDir });

expect(existsSync(join(tmpDir, ".vscode/cline_mcp_settings.json"))).toBe(true);
});
});
Loading
Loading