From cfe8dd9f9112fac071730cfe952626723b61b48c Mon Sep 17 00:00:00 2001 From: David Antoon Date: Sun, 25 Jan 2026 13:30:58 +0200 Subject: [PATCH 1/5] feat: Implement skills HTTP configuration and visibility management --- CLAUDE.md | 124 +++++ .../e2e/skills-http.e2e.test.ts | 436 ++++++++++++++++++ .../e2e/skills-only-mode.e2e.test.ts | 208 +++++++++ .../demo-e2e-skills/src/apps/skills/index.ts | 12 +- .../src/apps/skills/skills/http-only.skill.ts | 24 + .../src/apps/skills/skills/index.ts | 2 + .../src/apps/skills/skills/mcp-only.skill.ts | 24 + apps/e2e/demo-e2e-skills/src/main.ts | 5 + .../__tests__/session-id.utils.test.ts | 42 ++ .../auth/session/utils/session-id.utils.ts | 8 + libs/sdk/src/common/entries/tool.entry.ts | 29 ++ .../src/common/metadata/front-mcp.metadata.ts | 30 ++ .../sdk/src/common/metadata/skill.metadata.ts | 31 ++ .../sdk/src/common/tokens/front-mcp.tokens.ts | 2 + libs/sdk/src/common/tokens/skill.tokens.ts | 1 + .../src/common/types/auth/session.types.ts | 7 + .../__tests__/skills-http.options.test.ts | 294 ++++++++++++ libs/sdk/src/common/types/options/index.ts | 1 + .../common/types/options/skills-http/index.ts | 18 + .../types/options/skills-http/interfaces.ts | 340 ++++++++++++++ .../types/options/skills-http/schema.ts | 156 +++++++ libs/sdk/src/scope/scope.instance.ts | 34 +- .../skill/__tests__/skill-http.utils.test.ts | 386 ++++++++++++++++ libs/sdk/src/skill/auth/index.ts | 12 + libs/sdk/src/skill/auth/skill-http-auth.ts | 238 ++++++++++ libs/sdk/src/skill/cache/index.ts | 24 + .../skill/cache/skill-http-cache.factory.ts | 169 +++++++ .../skill/cache/skill-http-cache.holder.ts | 136 ++++++ libs/sdk/src/skill/cache/skill-http-cache.ts | 318 +++++++++++++ libs/sdk/src/skill/flows/http/index.ts | 14 + .../src/skill/flows/http/llm-full-txt.flow.ts | 196 ++++++++ libs/sdk/src/skill/flows/http/llm-txt.flow.ts | 204 ++++++++ .../src/skill/flows/http/skills-api.flow.ts | 355 ++++++++++++++ libs/sdk/src/skill/flows/index.ts | 3 + libs/sdk/src/skill/flows/load-skill.flow.ts | 325 ++++++++----- .../sdk/src/skill/flows/search-skills.flow.ts | 8 +- libs/sdk/src/skill/index.ts | 39 +- .../skill/providers/memory-skill.provider.ts | 13 +- libs/sdk/src/skill/skill-http.utils.ts | 306 ++++++++++++ libs/sdk/src/skill/skill-mode.utils.ts | 75 +++ libs/sdk/src/skill/skill-scope.helper.ts | 125 +++++ libs/sdk/src/skill/skill.instance.ts | 67 +-- libs/sdk/src/skill/skill.registry.ts | 50 +- libs/sdk/src/skill/skill.utils.ts | 77 ++++ libs/sdk/src/skill/tools/index.ts | 11 +- libs/sdk/src/skill/tools/load-skill.tool.ts | 135 ------ libs/sdk/src/skill/tools/load-skills.tool.ts | 251 ++++++++++ .../sdk/src/skill/tools/search-skills.tool.ts | 71 ++- libs/sdk/src/tool/flows/tools-list.flow.ts | 10 +- .../src/transport/flows/handle.sse.flow.ts | 6 + .../flows/handle.streamable-http.flow.ts | 6 + .../src/client/mcp-test-client.builder.ts | 16 + libs/testing/src/client/mcp-test-client.ts | 16 +- .../src/client/mcp-test-client.types.ts | 10 + 54 files changed, 5171 insertions(+), 329 deletions(-) create mode 100644 apps/e2e/demo-e2e-skills/e2e/skills-http.e2e.test.ts create mode 100644 apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts create mode 100644 apps/e2e/demo-e2e-skills/src/apps/skills/skills/http-only.skill.ts create mode 100644 apps/e2e/demo-e2e-skills/src/apps/skills/skills/mcp-only.skill.ts create mode 100644 libs/sdk/src/common/types/options/__tests__/skills-http.options.test.ts create mode 100644 libs/sdk/src/common/types/options/skills-http/index.ts create mode 100644 libs/sdk/src/common/types/options/skills-http/interfaces.ts create mode 100644 libs/sdk/src/common/types/options/skills-http/schema.ts create mode 100644 libs/sdk/src/skill/__tests__/skill-http.utils.test.ts create mode 100644 libs/sdk/src/skill/auth/index.ts create mode 100644 libs/sdk/src/skill/auth/skill-http-auth.ts create mode 100644 libs/sdk/src/skill/cache/index.ts create mode 100644 libs/sdk/src/skill/cache/skill-http-cache.factory.ts create mode 100644 libs/sdk/src/skill/cache/skill-http-cache.holder.ts create mode 100644 libs/sdk/src/skill/cache/skill-http-cache.ts create mode 100644 libs/sdk/src/skill/flows/http/index.ts create mode 100644 libs/sdk/src/skill/flows/http/llm-full-txt.flow.ts create mode 100644 libs/sdk/src/skill/flows/http/llm-txt.flow.ts create mode 100644 libs/sdk/src/skill/flows/http/skills-api.flow.ts create mode 100644 libs/sdk/src/skill/skill-http.utils.ts create mode 100644 libs/sdk/src/skill/skill-mode.utils.ts create mode 100644 libs/sdk/src/skill/skill-scope.helper.ts delete mode 100644 libs/sdk/src/skill/tools/load-skill.tool.ts create mode 100644 libs/sdk/src/skill/tools/load-skills.tool.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5be95cd9..c2cc5b09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -464,6 +464,130 @@ class MyTool extends ToolContext { } ``` +## Skills Feature Organization + +### Keep scope.instance.ts Lean + +Use helper functions for feature-specific registration logic: + +```typescript +// ✅ Good - use helper from skill module +import { registerSkillCapabilities } from '../skill/skill-scope.helper'; + +await registerSkillCapabilities({ + skillRegistry: this.scopeSkills, + flowRegistry: this.scopeFlows, + toolRegistry: this.scopeTools, + providers: this.scopeProviders, + skillsConfig: this.metadata.skillsConfig, + logger: this.logger, +}); + +// ❌ Bad - inline 40+ lines of skill registration logic in scope.instance.ts +``` + +### Skills-Only Mode Detection + +Use the utility from skill module instead of inline detection: + +```typescript +// ✅ Good - use utility +import { detectSkillsOnlyMode } from '../../skill/skill-mode.utils'; +const skillsOnlyMode = detectSkillsOnlyMode(query); + +// ❌ Bad - duplicated inline logic +const mode = query?.['mode']; +const skillsOnlyMode = mode === 'skills_only' || (Array.isArray(mode) && mode.includes('skills_only')); +``` + +### Type Usage for Visibility + +Use the `SkillVisibility` type from common/metadata instead of inline literals: + +```typescript +// ✅ Good - use exported type +import { SkillVisibility } from '../common/metadata/skill.metadata'; +private readonly visibility: SkillVisibility; + +// ❌ Bad - inline literal union +private readonly visibility: 'mcp' | 'http' | 'both'; +``` + +### Private Fields in Entry Classes + +Use `private` keyword without underscore prefix, expose via getters: + +```typescript +// ✅ Good - idiomatic TypeScript +private readonly tags: string[]; +private readonly priority: number; +private cachedContent?: CachedSkillContent; + +getTags(): string[] { return this.tags; } +getPriority(): number { return this.priority; } + +// ❌ Bad - underscore prefix pattern +private readonly _tags: string[]; +getTags(): string[] { return this._tags; } +``` + +### Tool Schema Access + +Use `ToolEntry.getInputJsonSchema()` for single source of truth: + +```typescript +// ✅ Good - use entry method +const inputSchema = tool.getInputJsonSchema(); + +// ❌ Bad - duplicated conversion logic +if (tool.rawInputSchema) { ... } +else if (tool.inputSchema) { try { toJSONSchema(z.object(...)) } } +``` + +### Skills HTTP Caching + +Configure via `skillsConfig.cache` option, supports memory (default) and Redis: + +```typescript +@FrontMcp({ + skillsConfig: { + enabled: true, + cache: { + enabled: true, + redis: { provider: 'redis', host: 'localhost' }, + ttlMs: 60000, + }, + }, +}) +``` + +### Skills HTTP Authentication + +Configure via `skillsConfig.auth` option: + +```typescript +// API key auth +@FrontMcp({ + skillsConfig: { + enabled: true, + auth: 'api-key', + apiKeys: ['sk-xxx', 'sk-yyy'], + }, +}) + +// JWT bearer auth +@FrontMcp({ + skillsConfig: { + enabled: true, + auth: 'bearer', + jwt: { + issuer: 'https://auth.example.com', + audience: 'skills-api', + }, + }, +}) +``` + ## Anti-Patterns to Avoid ❌ **Don't**: Use `node:crypto` directly (use `@frontmcp/utils` for cross-platform support) diff --git a/apps/e2e/demo-e2e-skills/e2e/skills-http.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/skills-http.e2e.test.ts new file mode 100644 index 00000000..700c1327 --- /dev/null +++ b/apps/e2e/demo-e2e-skills/e2e/skills-http.e2e.test.ts @@ -0,0 +1,436 @@ +/** + * E2E Tests for Skills HTTP Endpoints + * + * Tests HTTP endpoints for skill discovery: + * - /llm.txt - Compact skill summaries (plain text) + * - /llm_full.txt - Full skills with instructions and tool schemas + * - /skills - List all skills (JSON API) + * - /skills/{id} - Get specific skill by ID + * - Visibility filtering (mcp-only, http-only, both) + */ +import { test, expect } from '@frontmcp/testing'; + +interface SkillApiResponse { + id: string; + name: string; + description: string; + tags: string[]; + tools: string[]; + parameters?: Array<{ + name: string; + description?: string; + required: boolean; + type: string; + }>; + priority: number; + visibility: 'mcp' | 'http' | 'both'; + availableTools?: string[]; + missingTools?: string[]; + isComplete?: boolean; +} + +interface SkillsListResponse { + skills: SkillApiResponse[]; + total: number; +} + +interface SkillDetailResponse extends SkillApiResponse { + instructions?: string; + formattedContent?: string; +} + +test.describe('Skills HTTP Endpoints E2E', () => { + test.use({ + server: 'apps/e2e/demo-e2e-skills/src/main.ts', + project: 'demo-e2e-skills', + publicMode: true, + }); + + // ============================================ + // /llm.txt Endpoint Tests + // ============================================ + + test.describe('GET /llm.txt', () => { + test('should return compact skill summaries in plain text', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/plain'); + + const content = await response.text(); + expect(content).toBeTruthy(); + + // Should contain skill headers + expect(content).toContain('# review-pr'); + expect(content).toContain('# notify-team'); + }); + + test('should include skill descriptions', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + expect(content).toContain('Review a GitHub pull request'); + }); + + test('should include tools for skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + expect(content).toContain('Tools:'); + expect(content).toContain('github_get_pr'); + }); + + test('should include tags for skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + expect(content).toContain('Tags:'); + expect(content).toContain('github'); + }); + + test('should NOT include mcp-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + // mcp-only skill should not appear in HTTP endpoints + expect(content).not.toContain('# mcp-only-workflow'); + }); + + test('should include http-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + // http-only skill should appear in HTTP endpoints + expect(content).toContain('# http-only-workflow'); + }); + + test('should NOT include hidden skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + expect(content).not.toContain('# hidden-internal'); + }); + + test('should separate skills with divider', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm.txt`); + const content = await response.text(); + + expect(content).toContain('---'); + }); + }); + + // ============================================ + // /llm_full.txt Endpoint Tests + // ============================================ + + test.describe('GET /llm_full.txt', () => { + test('should return full skill content in plain text', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/plain'); + + const content = await response.text(); + expect(content).toBeTruthy(); + }); + + test('should include skill instructions', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + // Should contain full instructions from review-pr skill + expect(content).toContain('## Instructions'); + expect(content).toContain('PR Review Process'); + }); + + test('should include tool schemas for available tools', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + // Should include tool section with schemas + expect(content).toContain('## Tools'); + expect(content).toContain('**Input Schema:**'); + expect(content).toContain('```json'); + }); + + test('should mark tool availability status', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + // Available tools should be marked with checkmark + expect(content).toMatch(/\[✓\] github_get_pr/); + }); + + test('should show warnings for missing tools', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + // Deploy skill has missing tools + expect(content).toContain('**Warning:**'); + expect(content).toContain('Missing:'); + }); + + test('should include parameters section when defined', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + expect(content).toContain('## Parameters'); + expect(content).toContain('**pr_url** (required)'); + }); + + test('should NOT include mcp-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + expect(content).not.toContain('# Skill: mcp-only-workflow'); + }); + + test('should include http-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/llm_full.txt`); + const content = await response.text(); + + expect(content).toContain('# Skill: http-only-workflow'); + }); + }); + + // ============================================ + // /skills API Endpoint Tests + // ============================================ + + test.describe('GET /skills (List)', () => { + test('should return JSON array of skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills`); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + + const data: SkillsListResponse = await response.json(); + expect(data.skills).toBeDefined(); + expect(Array.isArray(data.skills)).toBe(true); + expect(data.total).toBeGreaterThan(0); + }); + + test('should include skill metadata in response', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills`); + const data: SkillsListResponse = await response.json(); + + const reviewPr = data.skills.find((s) => s.id === 'review-pr'); + expect(reviewPr).toBeDefined(); + expect(reviewPr!.name).toBe('review-pr'); + expect(reviewPr!.description).toBeTruthy(); + expect(reviewPr!.tags).toContain('github'); + expect(reviewPr!.tools).toContain('github_get_pr'); + }); + + test('should include visibility field', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills`); + const data: SkillsListResponse = await response.json(); + + for (const skill of data.skills) { + expect(['mcp', 'http', 'both']).toContain(skill.visibility); + } + }); + + test('should NOT include mcp-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills`); + const data: SkillsListResponse = await response.json(); + + const mcpOnly = data.skills.find((s) => s.id === 'mcp-only-workflow'); + expect(mcpOnly).toBeUndefined(); + }); + + test('should include http-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills`); + const data: SkillsListResponse = await response.json(); + + const httpOnly = data.skills.find((s) => s.id === 'http-only-workflow'); + expect(httpOnly).toBeDefined(); + expect(httpOnly!.visibility).toBe('http'); + }); + + test('should NOT include hidden skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills`); + const data: SkillsListResponse = await response.json(); + + const hidden = data.skills.find((s) => s.id === 'hidden-internal'); + expect(hidden).toBeUndefined(); + }); + }); + + test.describe('GET /skills?query=X (Search)', () => { + test('should filter skills by query', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills?query=review`); + + expect(response.status).toBe(200); + + const data: SkillsListResponse = await response.json(); + expect(data.skills.length).toBeGreaterThan(0); + + // Should find review-pr skill + const skillIds = data.skills.map((s) => s.id); + expect(skillIds).toContain('review-pr'); + }); + + test('should filter skills by tags', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills?tags=slack`); + + expect(response.status).toBe(200); + + const data: SkillsListResponse = await response.json(); + // All skills should have the slack tag + for (const skill of data.skills) { + expect(skill.tags).toContain('slack'); + } + }); + + test('should filter skills by tools', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills?tools=github_get_pr`); + + expect(response.status).toBe(200); + + const data: SkillsListResponse = await response.json(); + // All skills should use github_get_pr tool + for (const skill of data.skills) { + expect(skill.tools).toContain('github_get_pr'); + } + }); + + test('should respect limit parameter', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills?limit=2`); + + expect(response.status).toBe(200); + + const data: SkillsListResponse = await response.json(); + expect(data.skills.length).toBeLessThanOrEqual(2); + }); + }); + + test.describe('GET /skills/{id} (Get Single)', () => { + test('should return skill details by ID', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/review-pr`); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + + const data = await response.json(); + // The API wraps the skill in a `skill` property + expect(data.skill.id).toBe('review-pr'); + expect(data.skill.name).toBe('review-pr'); + expect(data.skill.description).toBeTruthy(); + }); + + test('should include tool availability info', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/review-pr`); + const data = await response.json(); + + expect(data.availableTools).toBeDefined(); + expect(data.missingTools).toBeDefined(); + expect(data.isComplete).toBeDefined(); + + // review-pr should be complete (all tools available) + expect(data.availableTools).toContain('github_get_pr'); + expect(data.isComplete).toBe(true); + }); + + test('should show missing tools for incomplete skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/deploy-app`); + const data = await response.json(); + + expect(data.missingTools).toBeDefined(); + expect(data.missingTools!.length).toBeGreaterThan(0); + expect(data.isComplete).toBe(false); + }); + + test('should include formatted content', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/review-pr`); + const data = await response.json(); + + expect(data.formattedContent).toBeDefined(); + expect(data.formattedContent).toContain('review-pr'); + }); + + test('should return 404 for non-existent skill', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/non-existent-xyz`); + + expect(response.status).toBe(404); + + const data = await response.json(); + expect(data.error).toBeTruthy(); + }); + + test('should allow loading hidden skills directly by ID', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/hidden-internal`); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.skill.id).toBe('hidden-internal'); + }); + + test('should allow loading http-only skills', async ({ server }) => { + const response = await fetch(`${server.info.baseUrl}/skills/http-only-workflow`); + + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.skill.id).toBe('http-only-workflow'); + }); + }); + + // ============================================ + // Visibility Filtering Tests + // ============================================ + + test.describe('Visibility Filtering', () => { + test('mcp-only skill should be visible via MCP but not HTTP', async ({ mcp, server }) => { + // Via MCP - should find the skill + const mcpResult = await mcp.tools.call('searchSkills', { + query: 'mcp-only-workflow', + }); + expect(mcpResult).toBeSuccessful(); + const mcpContent = mcpResult.json<{ skills: Array<{ id: string }> }>(); + const mcpSkillIds = mcpContent.skills.map((s) => s.id); + expect(mcpSkillIds).toContain('mcp-only-workflow'); + + // Via HTTP - should NOT find the skill + const httpResponse = await fetch(`${server.info.baseUrl}/skills`); + const httpData: SkillsListResponse = await httpResponse.json(); + const httpSkillIds = httpData.skills.map((s) => s.id); + expect(httpSkillIds).not.toContain('mcp-only-workflow'); + }); + + test('http-only skill should be visible via HTTP but not MCP', async ({ mcp, server }) => { + // Via HTTP - should find the skill + const httpResponse = await fetch(`${server.info.baseUrl}/skills`); + const httpData: SkillsListResponse = await httpResponse.json(); + const httpSkillIds = httpData.skills.map((s) => s.id); + expect(httpSkillIds).toContain('http-only-workflow'); + + // Via MCP - should NOT find the skill + const mcpResult = await mcp.tools.call('searchSkills', { + query: 'http-only-workflow', + }); + expect(mcpResult).toBeSuccessful(); + const mcpContent = mcpResult.json<{ skills: Array<{ id: string }> }>(); + const mcpSkillIds = mcpContent.skills.map((s) => s.id); + expect(mcpSkillIds).not.toContain('http-only-workflow'); + }); + + test('both-visibility skills should be visible everywhere', async ({ mcp, server }) => { + // Via MCP - should find review-pr + const mcpResult = await mcp.tools.call('searchSkills', { + query: 'review-pr', + }); + expect(mcpResult).toBeSuccessful(); + const mcpContent = mcpResult.json<{ skills: Array<{ id: string }> }>(); + const mcpSkillIds = mcpContent.skills.map((s) => s.id); + expect(mcpSkillIds).toContain('review-pr'); + + // Via HTTP - should also find review-pr + const httpResponse = await fetch(`${server.info.baseUrl}/skills`); + const httpData: SkillsListResponse = await httpResponse.json(); + const httpSkillIds = httpData.skills.map((s) => s.id); + expect(httpSkillIds).toContain('review-pr'); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts new file mode 100644 index 00000000..52e83785 --- /dev/null +++ b/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts @@ -0,0 +1,208 @@ +/** + * E2E Tests for MCP Skills-Only Mode + * + * Tests the `?mode=skills_only` query parameter for MCP connections: + * - tools/list returns empty array (tools hidden from discovery) + * - skills/search and skills/load work normally + * - Tool execution still works (not blocked, just hidden) + * + * This mode is useful for planner agents that: + * - Fetch skills to create execution plans + * - Delegate tool execution to sub-agents + */ +import { test, expect } from '@frontmcp/testing'; + +interface LoadSkillResult { + skill: { + id: string; + name: string; + description: string; + instructions: string; + tools: Array<{ name: string; available: boolean }>; + }; + availableTools: string[]; + missingTools: string[]; + isComplete: boolean; + formattedContent: string; +} + +test.describe('MCP Skills-Only Mode E2E', () => { + test.use({ + server: 'apps/e2e/demo-e2e-skills/src/main.ts', + project: 'demo-e2e-skills', + publicMode: true, + }); + + test.describe('Normal Mode (default)', () => { + test('should list tools normally', async ({ mcp }) => { + const tools = await mcp.tools.list(); + + // Should have tools listed + expect(tools.length).toBeGreaterThan(0); + expect(tools).toContainTool('github_get_pr'); + expect(tools).toContainTool('github_add_comment'); + expect(tools).toContainTool('slack_notify'); + }); + + test('should list searchSkills and loadSkill tools', async ({ mcp }) => { + const tools = await mcp.tools.list(); + + expect(tools).toContainTool('searchSkills'); + expect(tools).toContainTool('loadSkill'); + }); + }); + + // TODO: Skills-only mode requires transport-level implementation + // See plan: MCP connections with ?mode=skills_only should hide tools from discovery + test.describe.skip('Skills-Only Mode', () => { + test('should return empty tools list in skills-only mode', async ({ server }) => { + // Create a client that connects with skills_only mode + const builder = server.createClientBuilder(); + const client = await builder + .withTransport('streamable-http') + .withQueryParams({ mode: 'skills_only' }) + .buildAndConnect(); + + try { + const tools = await client.tools.list(); + + // In skills-only mode, tools/list should return empty array + expect(tools.length).toBe(0); + } finally { + await client.disconnect(); + } + }); + + test('should still allow searchSkills in skills-only mode', async ({ server }) => { + const builder = server.createClientBuilder(); + const client = await builder + .withTransport('streamable-http') + .withQueryParams({ mode: 'skills_only' }) + .buildAndConnect(); + + try { + // searchSkills should work even though tools aren't listed + const result = await client.tools.call('searchSkills', { + query: 'review', + }); + + expect(result).toBeSuccessful(); + + const content = result.json<{ skills: Array<{ id: string }> }>(); + expect(content.skills).toBeDefined(); + expect(content.skills.length).toBeGreaterThan(0); + expect(content.skills.map((s) => s.id)).toContain('review-pr'); + } finally { + await client.disconnect(); + } + }); + + test('should still allow loadSkill in skills-only mode', async ({ server }) => { + const builder = server.createClientBuilder(); + const client = await builder + .withTransport('streamable-http') + .withQueryParams({ mode: 'skills_only' }) + .buildAndConnect(); + + try { + const result = await client.tools.call('loadSkill', { + skillId: 'review-pr', + }); + + expect(result).toBeSuccessful(); + + const content = result.json(); + expect(content.skill).toBeDefined(); + expect(content.skill.id).toBe('review-pr'); + expect(content.formattedContent).toBeDefined(); + } finally { + await client.disconnect(); + } + }); + + test('should still allow tool execution in skills-only mode', async ({ server }) => { + const builder = server.createClientBuilder(); + const client = await builder + .withTransport('streamable-http') + .withQueryParams({ mode: 'skills_only' }) + .buildAndConnect(); + + try { + // Even though tools aren't listed, they can still be executed + // (the planner delegated the tool name to a sub-agent which knows it) + const result = await client.tools.call('github_get_pr', { + pr_url: 'https://github.com/owner/repo/pull/123', + }); + + expect(result).toBeSuccessful(); + } finally { + await client.disconnect(); + } + }); + }); + + // Note: SSE transport tests are skipped because SSE transport is not yet implemented + // in the test client. Uncomment these tests when SSE transport is available. + test.describe.skip('SSE Transport with Skills-Only Mode', () => { + test('should return empty tools list via SSE in skills-only mode', async ({ server }) => { + const builder = server.createClientBuilder(); + const client = await builder.withTransport('sse').withQueryParams({ mode: 'skills_only' }).buildAndConnect(); + + try { + const tools = await client.tools.list(); + expect(tools.length).toBe(0); + } finally { + await client.disconnect(); + } + }); + + test('should allow skill operations via SSE in skills-only mode', async ({ server }) => { + const builder = server.createClientBuilder(); + const client = await builder.withTransport('sse').withQueryParams({ mode: 'skills_only' }).buildAndConnect(); + + try { + const result = await client.tools.call('searchSkills', { + query: 'deploy', + }); + + expect(result).toBeSuccessful(); + + const content = result.json<{ skills: Array<{ id: string }> }>(); + expect(content.skills).toBeDefined(); + } finally { + await client.disconnect(); + } + }); + }); + + // TODO: Skills-only mode requires transport-level implementation + test.describe.skip('Mixed Mode Clients', () => { + test('normal client and skills-only client can coexist', async ({ mcp, server }) => { + // Normal client should see tools + const normalTools = await mcp.tools.list(); + expect(normalTools.length).toBeGreaterThan(0); + expect(normalTools).toContainTool('github_get_pr'); + + // Skills-only client should not see tools + const builder = server.createClientBuilder(); + const skillsOnlyClient = await builder + .withTransport('streamable-http') + .withQueryParams({ mode: 'skills_only' }) + .buildAndConnect(); + + try { + const skillsOnlyTools = await skillsOnlyClient.tools.list(); + expect(skillsOnlyTools.length).toBe(0); + + // Both should be able to search skills + const normalSkillSearch = await mcp.tools.call('searchSkills', { query: 'review' }); + const skillsOnlySearch = await skillsOnlyClient.tools.call('searchSkills', { query: 'review' }); + + expect(normalSkillSearch).toBeSuccessful(); + expect(skillsOnlySearch).toBeSuccessful(); + } finally { + await skillsOnlyClient.disconnect(); + } + }); + }); +}); diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/index.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/index.ts index fcc235d7..ab3c1e26 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/index.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/index.ts @@ -4,12 +4,20 @@ import { GitHubAddCommentTool } from './tools/github-add-comment.tool'; import { SlackNotifyTool } from './tools/slack-notify.tool'; import { AdminActionTool } from './tools/admin-action.tool'; import { DevOpsPlugin } from './plugins/devops-plugin'; -import { ReviewPRSkill, NotifyTeamSkill, HiddenSkill, DeploySkill, FullPRWorkflowSkill } from './skills'; +import { + ReviewPRSkill, + NotifyTeamSkill, + HiddenSkill, + DeploySkill, + FullPRWorkflowSkill, + McpOnlySkill, + HttpOnlySkill, +} from './skills'; @App({ name: 'skills-e2e', tools: [GitHubGetPRTool, GitHubAddCommentTool, SlackNotifyTool, AdminActionTool], - skills: [ReviewPRSkill, NotifyTeamSkill, HiddenSkill, DeploySkill, FullPRWorkflowSkill], + skills: [ReviewPRSkill, NotifyTeamSkill, HiddenSkill, DeploySkill, FullPRWorkflowSkill, McpOnlySkill, HttpOnlySkill], plugins: [DevOpsPlugin], }) export class SkillsE2EApp {} diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/skills/http-only.skill.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/skills/http-only.skill.ts new file mode 100644 index 00000000..635f4fce --- /dev/null +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/skills/http-only.skill.ts @@ -0,0 +1,24 @@ +import { Skill } from '@frontmcp/sdk'; + +/** + * HTTP-Only Skill - only visible via HTTP endpoints (/llm.txt, /skills) + * Not visible via MCP tools (searchSkills/loadSkills) + */ +@Skill({ + name: 'http-only-workflow', + description: 'A workflow that is only discoverable via HTTP endpoints', + instructions: ` +## HTTP-Only Workflow + +This skill is only accessible via HTTP endpoints and should not appear in MCP search results. + +### Steps +1. Fetch the resource via HTTP +2. Process the data +3. Return the result +`, + tags: ['http', 'api', 'workflow'], + visibility: 'http', + parameters: [{ name: 'endpoint', description: 'The HTTP endpoint to call', required: true, type: 'string' }], +}) +export class HttpOnlySkill {} diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/skills/index.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/skills/index.ts index 85750713..076662e1 100644 --- a/apps/e2e/demo-e2e-skills/src/apps/skills/skills/index.ts +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/skills/index.ts @@ -3,3 +3,5 @@ export { NotifyTeamSkill } from './notify-team.skill'; export { HiddenSkill } from './hidden-internal.skill'; export { DeploySkill } from './deploy-app.skill'; export { FullPRWorkflowSkill } from './full-pr-workflow.skill'; +export { McpOnlySkill } from './mcp-only.skill'; +export { HttpOnlySkill } from './http-only.skill'; diff --git a/apps/e2e/demo-e2e-skills/src/apps/skills/skills/mcp-only.skill.ts b/apps/e2e/demo-e2e-skills/src/apps/skills/skills/mcp-only.skill.ts new file mode 100644 index 00000000..196bd6e4 --- /dev/null +++ b/apps/e2e/demo-e2e-skills/src/apps/skills/skills/mcp-only.skill.ts @@ -0,0 +1,24 @@ +import { Skill } from '@frontmcp/sdk'; + +/** + * MCP-Only Skill - only visible via MCP tools (searchSkills/loadSkills) + * Not visible via HTTP endpoints (/llm.txt, /skills) + */ +@Skill({ + name: 'mcp-only-workflow', + description: 'A workflow that is only discoverable via MCP protocol', + instructions: ` +## MCP-Only Workflow + +This skill is only accessible via MCP tools and should not appear in HTTP endpoint responses. + +### Steps +1. Use github_get_pr to fetch PR details +2. Review the changes +3. Provide feedback +`, + tools: [{ name: 'github_get_pr', purpose: 'Fetch PR details' }], + tags: ['mcp', 'workflow'], + visibility: 'mcp', +}) +export class McpOnlySkill {} diff --git a/apps/e2e/demo-e2e-skills/src/main.ts b/apps/e2e/demo-e2e-skills/src/main.ts index 63cf48af..b02b54c8 100644 --- a/apps/e2e/demo-e2e-skills/src/main.ts +++ b/apps/e2e/demo-e2e-skills/src/main.ts @@ -16,5 +16,10 @@ const port = parseInt(process.env['PORT'] ?? '3107', 10); transport: { protocol: { json: true, legacy: true, strictSession: false }, }, + skillsConfig: { + enabled: true, + auth: 'public', // Single auth config for all HTTP endpoints + mcpTools: true, // Keep searchSkills/loadSkills MCP tools enabled + }, }) export default class Server {} diff --git a/libs/sdk/src/auth/session/__tests__/session-id.utils.test.ts b/libs/sdk/src/auth/session/__tests__/session-id.utils.test.ts index ce5b9811..d0fc779a 100644 --- a/libs/sdk/src/auth/session/__tests__/session-id.utils.test.ts +++ b/libs/sdk/src/auth/session/__tests__/session-id.utils.test.ts @@ -177,6 +177,40 @@ describe('session-id.utils', () => { expect(mockEncryptValue).toHaveBeenCalled(); expect(result.id).toBe('test-iv.test-tag.test-data'); }); + + it('should set skillsOnlyMode when provided in options', () => { + const result = createSessionId('streamable-http', TEST_TOKEN, { + skillsOnlyMode: true, + }); + + expect(result.payload.skillsOnlyMode).toBe(true); + }); + + it('should not set skillsOnlyMode when not provided', () => { + const result = createSessionId('streamable-http', TEST_TOKEN); + + expect(result.payload.skillsOnlyMode).toBeUndefined(); + }); + + it('should not set skillsOnlyMode when explicitly false', () => { + const result = createSessionId('streamable-http', TEST_TOKEN, { + skillsOnlyMode: false, + }); + + expect(result.payload.skillsOnlyMode).toBeUndefined(); + }); + + it('should combine skillsOnlyMode with other options', () => { + mockDetectPlatformFromUserAgent.mockReturnValue('cursor'); + + const result = createSessionId('streamable-http', TEST_TOKEN, { + userAgent: 'Cursor/1.0', + skillsOnlyMode: true, + }); + + expect(result.payload.skillsOnlyMode).toBe(true); + expect(result.payload.platformType).toBe('cursor'); + }); }); // ============================================ @@ -239,6 +273,14 @@ describe('session-id.utils', () => { expect(payload.platformType).toBe('openai'); }); + it('should update skillsOnlyMode field', () => { + const { id, payload } = createSessionId('streamable-http', TEST_TOKEN); + + updateSessionPayload(id, { skillsOnlyMode: true }); + + expect(payload.skillsOnlyMode).toBe(true); + }); + it('should return false for non-existent session', () => { mockDecryptValue.mockReturnValue(null); diff --git a/libs/sdk/src/auth/session/utils/session-id.utils.ts b/libs/sdk/src/auth/session/utils/session-id.utils.ts index 19b83403..df8b190b 100644 --- a/libs/sdk/src/auth/session/utils/session-id.utils.ts +++ b/libs/sdk/src/auth/session/utils/session-id.utils.ts @@ -181,6 +181,12 @@ export interface CreateSessionOptions { userAgent?: string; /** Platform detection configuration from scope */ platformDetectionConfig?: PlatformDetectionConfig; + /** + * Whether this session is in skills-only mode. + * When true, tools/list returns empty array but skills/search and skills/load work normally. + * Detected from `?mode=skills_only` query param on connection. + */ + skillsOnlyMode?: boolean; } export function createSessionId(protocol: TransportProtocolType, token: string, options?: CreateSessionOptions) { @@ -203,6 +209,8 @@ export function createSessionId(protocol: TransportProtocolType, token: string, iat: nowSec(), protocol, platformType, + // Add skillsOnlyMode if provided + ...(options?.skillsOnlyMode && { skillsOnlyMode: true }), }; const id = encryptJson(payload); cache.set(id, payload); diff --git a/libs/sdk/src/common/entries/tool.entry.ts b/libs/sdk/src/common/entries/tool.entry.ts index 3a59d6ca..296ca316 100644 --- a/libs/sdk/src/common/entries/tool.entry.ts +++ b/libs/sdk/src/common/entries/tool.entry.ts @@ -75,6 +75,35 @@ export abstract class ToolEntry< return this.rawOutputSchema; } + /** + * Get the tool's input schema as JSON Schema. + * Returns rawInputSchema if available, otherwise converts from Zod schema shape. + * + * This is the single source of truth for tool input schema conversion. + * Used by skill HTTP utilities and other consumers needing JSON Schema format. + * + * @returns JSON Schema object or null if no schema is available + */ + getInputJsonSchema(): Record | null { + // Prefer rawInputSchema if already in JSON Schema format + if (this.rawInputSchema) { + return this.rawInputSchema; + } + + // Convert Zod schema shape to JSON Schema + if (this.inputSchema && Object.keys(this.inputSchema).length > 0) { + try { + // Dynamic import to avoid circular dependencies + const { z, toJSONSchema } = require('zod'); + return toJSONSchema(z.object(this.inputSchema)); + } catch { + return { type: 'object', properties: {} }; + } + } + + return null; + } + /** * Create a tool context (class or function wrapper). */ diff --git a/libs/sdk/src/common/metadata/front-mcp.metadata.ts b/libs/sdk/src/common/metadata/front-mcp.metadata.ts index 780026de..24d0c5b0 100644 --- a/libs/sdk/src/common/metadata/front-mcp.metadata.ts +++ b/libs/sdk/src/common/metadata/front-mcp.metadata.ts @@ -20,6 +20,8 @@ import { LoggingOptionsInput, ElicitationOptionsInput, elicitationOptionsSchema, + SkillsConfigOptionsInput, + skillsConfigOptionsSchema, } from '../types'; import { annotatedFrontMcpAppSchema, @@ -103,6 +105,33 @@ export interface FrontMcpBaseMetadata { * @default { enabled: false } */ elicitation?: ElicitationOptionsInput; + + /** + * Skills HTTP endpoints configuration. + * Controls exposure of skills via HTTP endpoints for multi-agent architectures. + * + * When enabled, provides: + * - GET /llm.txt - Compact skill summaries + * - GET /llm_full.txt - Full skills with instructions and tool schemas + * - GET /skills - JSON API for listing/searching skills + * - GET /skills/{id} - Load specific skill by ID + * + * @default { enabled: false } + * + * @example Enable skills HTTP endpoints + * ```typescript + * skillsConfig: { enabled: true } + * ``` + * + * @example HTTP-only (disable MCP tools) + * ```typescript + * skillsConfig: { + * enabled: true, + * mcpTools: false, // No searchSkills/loadSkill MCP tools + * } + * ``` + */ + skillsConfig?: SkillsConfigOptionsInput; } export const frontMcpBaseSchema = z.object({ @@ -121,6 +150,7 @@ export const frontMcpBaseSchema = z.object({ logging: loggingOptionsSchema.optional(), pagination: paginationOptionsSchema.optional(), elicitation: elicitationOptionsSchema.optional(), + skillsConfig: skillsConfigOptionsSchema.optional(), } satisfies RawZodShape); export interface FrontMcpMultiAppMetadata extends FrontMcpBaseMetadata { diff --git a/libs/sdk/src/common/metadata/skill.metadata.ts b/libs/sdk/src/common/metadata/skill.metadata.ts index e93becd5..4b1abcc6 100644 --- a/libs/sdk/src/common/metadata/skill.metadata.ts +++ b/libs/sdk/src/common/metadata/skill.metadata.ts @@ -264,6 +264,30 @@ export interface SkillMetadata extends ExtendFrontMcpSkillMetadata { * ``` */ toolValidation?: 'strict' | 'warn' | 'ignore'; + + /** + * Where this skill is visible for discovery. + * Controls which discovery mechanisms can find this skill. + * + * - 'mcp': Only via searchSkills/loadSkill MCP tools + * - 'http': Only via HTTP API endpoints (/llm.txt, /skills) + * - 'both': Visible in both MCP and HTTP (default) + * + * Note: hideFromDiscovery=true hides from search but skill is still loadable by ID. + * + * @default 'both' + * + * @example HTTP-only skill (not visible via MCP searchSkills) + * ```typescript + * @Skill({ + * name: 'internal-process', + * visibility: 'http', + * instructions: { file: './internal.md' }, + * }) + * class InternalProcessSkill {} + * ``` + */ + visibility?: 'mcp' | 'http' | 'both'; } // ============================================ @@ -324,6 +348,12 @@ export type SkillToolValidationMode = 'strict' | 'warn' | 'ignore'; /** * Zod schema for validating SkillMetadata. */ +/** + * Visibility mode for skill discovery. + * Controls which mechanisms can find this skill. + */ +export type SkillVisibility = 'mcp' | 'http' | 'both'; + export const skillMetadataSchema = z .object({ id: z.string().optional(), @@ -337,6 +367,7 @@ export const skillMetadataSchema = z priority: z.number().optional().default(0), hideFromDiscovery: z.boolean().optional().default(false), toolValidation: z.enum(['strict', 'warn', 'ignore']).optional().default('warn'), + visibility: z.enum(['mcp', 'http', 'both']).optional().default('both'), } satisfies RawZodShape) .passthrough(); diff --git a/libs/sdk/src/common/tokens/front-mcp.tokens.ts b/libs/sdk/src/common/tokens/front-mcp.tokens.ts index 914faca9..299ad91d 100644 --- a/libs/sdk/src/common/tokens/front-mcp.tokens.ts +++ b/libs/sdk/src/common/tokens/front-mcp.tokens.ts @@ -30,4 +30,6 @@ export const FrontMcpTokens: RawMetadataShape = { pagination: tokenFactory.meta('pagination'), // elicitation configuration elicitation: tokenFactory.meta('elicitation'), + // skills HTTP configuration + skillsConfig: tokenFactory.meta('skillsConfig'), }; diff --git a/libs/sdk/src/common/tokens/skill.tokens.ts b/libs/sdk/src/common/tokens/skill.tokens.ts index 3fd804bd..0599f3d9 100644 --- a/libs/sdk/src/common/tokens/skill.tokens.ts +++ b/libs/sdk/src/common/tokens/skill.tokens.ts @@ -15,6 +15,7 @@ export const FrontMcpSkillTokens = { priority: tokenFactory.meta('skill:priority'), hideFromDiscovery: tokenFactory.meta('skill:hideFromDiscovery'), toolValidation: tokenFactory.meta('skill:toolValidation'), + visibility: tokenFactory.meta('skill:visibility'), metadata: tokenFactory.meta('skill:metadata'), // used in skill({}) construction } as const satisfies RawMetadataShape; diff --git a/libs/sdk/src/common/types/auth/session.types.ts b/libs/sdk/src/common/types/auth/session.types.ts index 94fa375a..5365c0d7 100644 --- a/libs/sdk/src/common/types/auth/session.types.ts +++ b/libs/sdk/src/common/types/auth/session.types.ts @@ -89,6 +89,12 @@ export type SessionIdPayload = { clientVersion?: string; /* Whether the client supports MCP elicitation (from initialize capabilities) */ supportsElicitation?: boolean; + /** + * Whether this session is in skills-only mode. + * When true, tools/list returns empty array but skills/search and skills/load work normally. + * This is useful for planner agents that only need skill information. + */ + skillsOnlyMode?: boolean; }; export const sessionIdPayloadSchema = z.object({ nodeId: z.string(), @@ -101,6 +107,7 @@ export const sessionIdPayloadSchema = z.object({ clientName: z.string().optional(), clientVersion: z.string().optional(), supportsElicitation: z.boolean().optional(), + skillsOnlyMode: z.boolean().optional(), } satisfies RawZodShape); export interface Authorization { diff --git a/libs/sdk/src/common/types/options/__tests__/skills-http.options.test.ts b/libs/sdk/src/common/types/options/__tests__/skills-http.options.test.ts new file mode 100644 index 00000000..88a70d2a --- /dev/null +++ b/libs/sdk/src/common/types/options/__tests__/skills-http.options.test.ts @@ -0,0 +1,294 @@ +// common/types/options/__tests__/skills-http.options.test.ts + +import { + skillsConfigOptionsSchema, + skillsConfigEndpointConfigSchema, + skillsConfigAuthModeSchema, + normalizeEndpointConfig, + normalizeSkillsConfigOptions, +} from '../skills-http'; + +describe('skillsConfigAuthModeSchema', () => { + it('should accept valid auth modes', () => { + const validModes = ['inherit', 'public', 'api-key', 'bearer']; + for (const mode of validModes) { + const result = skillsConfigAuthModeSchema.safeParse(mode); + expect(result.success).toBe(true); + } + }); + + it('should reject invalid auth modes', () => { + const result = skillsConfigAuthModeSchema.safeParse('invalid'); + expect(result.success).toBe(false); + }); +}); + +describe('skillsConfigEndpointConfigSchema', () => { + describe('default values', () => { + it('should apply default enabled=true', () => { + const result = skillsConfigEndpointConfigSchema.parse({}); + expect(result.enabled).toBe(true); + }); + }); + + describe('validation', () => { + it('should accept config with enabled and path', () => { + const result = skillsConfigEndpointConfigSchema.safeParse({ + enabled: true, + path: '/custom/path', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enabled).toBe(true); + expect(result.data.path).toBe('/custom/path'); + } + }); + + it('should accept disabled endpoint', () => { + const result = skillsConfigEndpointConfigSchema.safeParse({ + enabled: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enabled).toBe(false); + } + }); + + it('should accept path only', () => { + const result = skillsConfigEndpointConfigSchema.safeParse({ + path: '/custom.txt', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.path).toBe('/custom.txt'); + expect(result.data.enabled).toBe(true); + } + }); + }); +}); + +describe('skillsConfigOptionsSchema', () => { + describe('default values', () => { + it('should apply default enabled=false', () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.enabled).toBe(false); + }); + + it('should apply default auth=inherit', () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.auth).toBe('inherit'); + }); + + it('should apply default mcpTools=true', () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.mcpTools).toBe(true); + }); + + it('should apply default llmTxt=true', () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.llmTxt).toBe(true); + }); + + it('should apply default llmFullTxt=true', () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.llmFullTxt).toBe(true); + }); + + it('should apply default api=true', () => { + const result = skillsConfigOptionsSchema.parse({}); + expect(result.api).toBe(true); + }); + }); + + describe('validation', () => { + it('should accept enabled configuration with top-level auth', () => { + const result = skillsConfigOptionsSchema.safeParse({ + enabled: true, + prefix: '/api', + auth: 'api-key', + apiKeys: ['sk-123'], + mcpTools: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enabled).toBe(true); + expect(result.data.prefix).toBe('/api'); + expect(result.data.auth).toBe('api-key'); + expect(result.data.apiKeys).toEqual(['sk-123']); + expect(result.data.mcpTools).toBe(false); + } + }); + + it('should accept endpoint boolean shorthand', () => { + const result = skillsConfigOptionsSchema.safeParse({ + llmTxt: false, + llmFullTxt: true, + api: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.llmTxt).toBe(false); + expect(result.data.llmFullTxt).toBe(true); + expect(result.data.api).toBe(false); + } + }); + + it('should accept endpoint config objects with path only', () => { + const result = skillsConfigOptionsSchema.safeParse({ + llmTxt: { path: '/custom/llm.txt' }, + api: { enabled: false }, + }); + expect(result.success).toBe(true); + }); + + it('should accept complete configuration', () => { + const result = skillsConfigOptionsSchema.safeParse({ + enabled: true, + prefix: '/v1', + auth: 'public', + llmTxt: { enabled: true, path: '/custom-llm.txt' }, + llmFullTxt: { enabled: false }, + api: { enabled: true }, + mcpTools: false, + }); + expect(result.success).toBe(true); + }); + + it('should reject empty apiKeys strings', () => { + const result = skillsConfigOptionsSchema.safeParse({ + auth: 'api-key', + apiKeys: [''], + }); + expect(result.success).toBe(false); + }); + }); +}); + +describe('normalizeEndpointConfig', () => { + it('should normalize undefined to enabled config with parent auth', () => { + const result = normalizeEndpointConfig(undefined, '/default.txt', 'public', ['key1']); + expect(result).toEqual({ + enabled: true, + path: '/default.txt', + auth: 'public', + apiKeys: ['key1'], + }); + }); + + it('should normalize true to enabled config with parent auth', () => { + const result = normalizeEndpointConfig(true, '/default.txt', 'api-key', ['sk-123']); + expect(result).toEqual({ + enabled: true, + path: '/default.txt', + auth: 'api-key', + apiKeys: ['sk-123'], + }); + }); + + it('should normalize false to disabled config with parent auth', () => { + const result = normalizeEndpointConfig(false, '/default.txt', 'bearer'); + expect(result).toEqual({ + enabled: false, + path: '/default.txt', + auth: 'bearer', + apiKeys: undefined, + }); + }); + + it('should normalize config object with custom path and parent auth', () => { + const result = normalizeEndpointConfig({ enabled: true, path: '/custom.txt' }, '/default.txt', 'api-key', [ + 'sk-123', + ]); + expect(result).toEqual({ + enabled: true, + path: '/custom.txt', + auth: 'api-key', + apiKeys: ['sk-123'], + }); + }); + + it('should use default path when config object has no path', () => { + const result = normalizeEndpointConfig({ enabled: true }, '/default.txt', 'inherit'); + expect(result.path).toBe('/default.txt'); + }); + + it('should always use parent auth regardless of endpoint config', () => { + const result = normalizeEndpointConfig({ path: '/custom.txt' }, '/default.txt', 'bearer'); + expect(result.auth).toBe('bearer'); + }); +}); + +describe('normalizeSkillsConfigOptions', () => { + it('should normalize undefined to default config', () => { + const result = normalizeSkillsConfigOptions(undefined); + expect(result.enabled).toBe(false); + expect(result.auth).toBe('inherit'); + expect(result.mcpTools).toBe(true); + expect(result.normalizedLlmTxt.enabled).toBe(true); + expect(result.normalizedLlmTxt.path).toBe('/llm.txt'); + expect(result.normalizedLlmTxt.auth).toBe('inherit'); + expect(result.normalizedLlmFullTxt.enabled).toBe(true); + expect(result.normalizedLlmFullTxt.path).toBe('/llm_full.txt'); + expect(result.normalizedLlmFullTxt.auth).toBe('inherit'); + expect(result.normalizedApi.enabled).toBe(true); + expect(result.normalizedApi.path).toBe('/skills'); + expect(result.normalizedApi.auth).toBe('inherit'); + }); + + it('should apply top-level auth to all endpoints', () => { + const result = normalizeSkillsConfigOptions({ + enabled: true, + auth: 'api-key', + apiKeys: ['sk-test'], + }); + expect(result.normalizedLlmTxt.auth).toBe('api-key'); + expect(result.normalizedLlmTxt.apiKeys).toEqual(['sk-test']); + expect(result.normalizedLlmFullTxt.auth).toBe('api-key'); + expect(result.normalizedLlmFullTxt.apiKeys).toEqual(['sk-test']); + expect(result.normalizedApi.auth).toBe('api-key'); + expect(result.normalizedApi.apiKeys).toEqual(['sk-test']); + }); + + it('should apply prefix to all endpoint paths', () => { + const result = normalizeSkillsConfigOptions({ enabled: true, prefix: '/api' }); + expect(result.normalizedLlmTxt.path).toBe('/api/llm.txt'); + expect(result.normalizedLlmFullTxt.path).toBe('/api/llm_full.txt'); + expect(result.normalizedApi.path).toBe('/api/skills'); + }); + + it('should preserve custom endpoint paths over prefix', () => { + const result = normalizeSkillsConfigOptions({ + enabled: true, + prefix: '/api', + llmTxt: { path: '/custom/llm.txt' }, + }); + expect(result.normalizedLlmTxt.path).toBe('/custom/llm.txt'); + expect(result.normalizedLlmFullTxt.path).toBe('/api/llm_full.txt'); + }); + + it('should handle mcpTools=false', () => { + const result = normalizeSkillsConfigOptions({ mcpTools: false }); + expect(result.mcpTools).toBe(false); + }); + + it('should handle disabled endpoints', () => { + const result = normalizeSkillsConfigOptions({ + llmTxt: false, + llmFullTxt: false, + api: false, + }); + expect(result.normalizedLlmTxt.enabled).toBe(false); + expect(result.normalizedLlmFullTxt.enabled).toBe(false); + expect(result.normalizedApi.enabled).toBe(false); + }); + + it('should use public auth for all endpoints when configured', () => { + const result = normalizeSkillsConfigOptions({ + enabled: true, + auth: 'public', + }); + expect(result.normalizedLlmTxt.auth).toBe('public'); + expect(result.normalizedLlmFullTxt.auth).toBe('public'); + expect(result.normalizedApi.auth).toBe('public'); + }); +}); diff --git a/libs/sdk/src/common/types/options/index.ts b/libs/sdk/src/common/types/options/index.ts index 7c8aafb3..f0609122 100644 --- a/libs/sdk/src/common/types/options/index.ts +++ b/libs/sdk/src/common/types/options/index.ts @@ -10,3 +10,4 @@ export * from './redis'; export * from './transport'; export * from './pagination'; export * from './elicitation'; +export * from './skills-http'; diff --git a/libs/sdk/src/common/types/options/skills-http/index.ts b/libs/sdk/src/common/types/options/skills-http/index.ts new file mode 100644 index 00000000..9a656a52 --- /dev/null +++ b/libs/sdk/src/common/types/options/skills-http/index.ts @@ -0,0 +1,18 @@ +// common/types/options/skills-http/index.ts +// Barrel export for skills HTTP options + +export type { + SkillsConfigOptions, + SkillsConfigEndpointConfig, + SkillsConfigAuthMode, + SkillsConfigJwtOptions, + SkillsConfigCacheOptions, +} from './interfaces'; +export { + skillsConfigOptionsSchema, + skillsConfigEndpointConfigSchema, + skillsConfigAuthModeSchema, + normalizeEndpointConfig, + normalizeSkillsConfigOptions, +} from './schema'; +export type { SkillsConfigOptionsInput, SkillsConfigEndpointConfigInput, NormalizedEndpointConfig } from './schema'; diff --git a/libs/sdk/src/common/types/options/skills-http/interfaces.ts b/libs/sdk/src/common/types/options/skills-http/interfaces.ts new file mode 100644 index 00000000..103720b5 --- /dev/null +++ b/libs/sdk/src/common/types/options/skills-http/interfaces.ts @@ -0,0 +1,340 @@ +// common/types/options/skills-http/interfaces.ts + +/** + * @module skillsConfig + * + * Skills HTTP Endpoints Configuration + * + * This module enables exposing FrontMCP skills via HTTP endpoints for multi-agent + * architectures where: + * - **Planner agents** fetch skills via HTTP to create execution plans + * - **Sub-agents** connect to specific apps for tool execution without skills + * - **Backend servers** fetch skills via HTTP GET before calling LLMs + * + * ## HTTP Endpoints + * + * When `skillsConfig.enabled: true`, the following endpoints are available: + * + * | Endpoint | Method | Description | + * |-------------------|--------|------------------------------------------------| + * | `/llm.txt` | GET | Compact skill summaries (name, description, tools, tags) | + * | `/llm_full.txt` | GET | Full skills with complete instructions and tool schemas | + * | `/skills` | GET | JSON API - List all skills | + * | `/skills?query=X` | GET | JSON API - Search skills with optional filters | + * | `/skills/{id}` | GET | JSON API - Get specific skill by ID/name | + * + * ## Visibility Control + * + * Skills can have a `visibility` property to control where they appear: + * - `'mcp'`: Only via searchSkills/loadSkill MCP tools + * - `'http'`: Only via HTTP API endpoints (/llm.txt, /skills) + * - `'both'`: Visible in both MCP and HTTP (default) + * + * ```typescript + * @Skill({ + * name: 'internal-process', + * visibility: 'http', // Only visible via HTTP endpoints + * instructions: { file: './internal.md' }, + * }) + * class InternalProcessSkill {} + * ``` + * + * ## Architecture Examples + * + * ### Multi-Agent Architecture + * ``` + * ┌──────────────────┐ HTTP GET /skills + * │ Planner Agent │ ──────────────────────> FrontMCP Server + * └────────┬─────────┘ │ + * │ creates plan │ /llm.txt + * ▼ │ /skills + * ┌──────────────────┐ MCP (tools only) ▼ + * │ Executor Agent │ <──────────────────── Tools (no skills listed) + * └──────────────────┘ + * ``` + * + * ### Backend Server Integration + * ```typescript + * // Fetch skills before calling LLM + * const skills = await fetch('https://api.example.com/llm.txt'); + * const systemPrompt = `Available skills:\n${await skills.text()}`; + * + * // Call LLM with skill context + * const response = await llm.chat({ system: systemPrompt, user: query }); + * ``` + * + * @see {@link SkillsConfigOptions} for configuration options + * @see {@link SkillMetadata.visibility} for per-skill visibility control + */ + +/** + * Authentication mode for skills HTTP endpoints. + * - 'inherit': Use the server's default authentication + * - 'public': No authentication required + * - 'api-key': Require API key in X-API-Key header or Authorization: ApiKey + * - 'bearer': Require JWT token, validated against configured issuer + */ +export type SkillsConfigAuthMode = 'inherit' | 'public' | 'api-key' | 'bearer'; + +/** + * JWT validation configuration for bearer auth mode. + */ +export interface SkillsConfigJwtOptions { + /** + * JWT issuer URL (e.g., 'https://auth.example.com'). + * Required when using bearer auth mode. + */ + issuer: string; + + /** + * Expected audience claim (optional). + * If provided, the JWT must have this audience. + */ + audience?: string; + + /** + * JWKS URL for key discovery. + * Defaults to {issuer}/.well-known/jwks.json + */ + jwksUrl?: string; +} + +/** + * Configuration for an individual skills HTTP endpoint. + * Controls whether the endpoint is enabled and its path. + * Authentication is configured at the top level of SkillsConfigOptions. + */ +export interface SkillsConfigEndpointConfig { + /** + * Whether this endpoint is enabled. + * @default true + */ + enabled?: boolean; + + /** + * Custom path override for this endpoint. + * If not specified, uses the default path. + */ + path?: string; +} + +/** + * Options for exposing skills via HTTP endpoints. + * + * When enabled, skills can be discovered and loaded via HTTP endpoints + * in addition to (or instead of) MCP tools. + * + * Authentication is configured once at the top level and applies to all + * HTTP endpoints. Individual endpoints can be enabled/disabled separately. + * + * @example Basic usage (public access) + * ```typescript + * @FrontMcp({ + * skillsConfig: { + * enabled: true, + * auth: 'public', + * }, + * }) + * ``` + * + * @example Protected API with API key + * ```typescript + * @FrontMcp({ + * skillsConfig: { + * enabled: true, + * auth: 'api-key', + * apiKeys: ['sk-xxx', 'sk-yyy'], + * }, + * }) + * ``` + * + * @example Custom paths with prefix + * ```typescript + * @FrontMcp({ + * skillsConfig: { + * enabled: true, + * prefix: '/api/v1', + * auth: 'public', + * llmTxt: { path: '/api/v1/llm.txt' }, + * api: { enabled: false }, // Disable JSON API + * }, + * }) + * ``` + * + * @example HTTP only (no MCP tools) + * ```typescript + * @FrontMcp({ + * skillsConfig: { + * enabled: true, + * auth: 'public', + * mcpTools: false, // No searchSkills/loadSkill MCP tools + * }, + * }) + * ``` + */ +export interface SkillsConfigOptions { + /** + * Whether skills HTTP endpoints are enabled. + * @default false (opt-in feature) + */ + enabled?: boolean; + + /** + * Prefix for all skills HTTP endpoints. + * @example '/api' results in '/api/llm.txt', '/api/skills', etc. + */ + prefix?: string; + + /** + * Authentication mode for all skills HTTP endpoints. + * This single setting applies to /llm.txt, /llm_full.txt, and /skills. + * + * - 'inherit': Use the server's default authentication (default) + * - 'public': No authentication required + * - 'api-key': Require API key in X-API-Key header + * - 'bearer': Require bearer token in Authorization header + * + * @default 'inherit' + */ + auth?: SkillsConfigAuthMode; + + /** + * API keys for 'api-key' authentication mode. + * Only used when auth is 'api-key'. + * Requests must include one of these keys in the X-API-Key header + * or in Authorization header as `ApiKey `. + */ + apiKeys?: string[]; + + /** + * JWT validation configuration for 'bearer' authentication mode. + * Required when auth is 'bearer'. + * + * @example + * ```typescript + * @FrontMcp({ + * skillsConfig: { + * enabled: true, + * auth: 'bearer', + * jwt: { + * issuer: 'https://auth.example.com', + * audience: 'skills-api', + * }, + * }, + * }) + * ``` + */ + jwt?: SkillsConfigJwtOptions; + + /** + * Configuration for /llm.txt endpoint. + * Provides compact skill summaries (name, description, tools, tags). + * + * Can be: + * - `true`: Enable with defaults + * - `false`: Disable this endpoint + * - `{ enabled?: boolean; path?: string }`: Custom configuration + * + * @default true (when skillsConfig.enabled is true) + */ + llmTxt?: SkillsConfigEndpointConfig | boolean; + + /** + * Configuration for /llm_full.txt endpoint. + * Provides full skill content with complete instructions and tool schemas. + * + * Can be: + * - `true`: Enable with defaults + * - `false`: Disable this endpoint + * - `{ enabled?: boolean; path?: string }`: Custom configuration + * + * @default true (when skillsConfig.enabled is true) + */ + llmFullTxt?: SkillsConfigEndpointConfig | boolean; + + /** + * Configuration for /skills API endpoints. + * Provides JSON API for listing, searching, and loading skills. + * + * Endpoints: + * - GET /skills - List all skills + * - GET /skills?query=X - Search skills + * - GET /skills/{id} - Get specific skill by ID/name + * + * Can be: + * - `true`: Enable with defaults + * - `false`: Disable this endpoint + * - `{ enabled?: boolean; path?: string }`: Custom configuration + * + * @default true (when skillsConfig.enabled is true) + */ + api?: SkillsConfigEndpointConfig | boolean; + + /** + * Whether to include searchSkills/loadSkill MCP tools. + * Set to false to expose skills only via HTTP endpoints. + * + * @default true + */ + mcpTools?: boolean; + + /** + * Cache configuration for HTTP endpoints. + * Reduces latency and CPU/memory overhead for repeated requests. + * + * @example Memory cache (default) + * ```typescript + * cache: { enabled: true, ttlMs: 30000 } + * ``` + * + * @example Redis cache + * ```typescript + * cache: { + * enabled: true, + * redis: { provider: 'redis', host: 'localhost' }, + * ttlMs: 60000, + * } + * ``` + */ + cache?: SkillsConfigCacheOptions; +} + +/** + * Cache configuration for skills HTTP endpoints. + */ +export interface SkillsConfigCacheOptions { + /** + * Whether caching is enabled. + * @default false (opt-in) + */ + enabled?: boolean; + + /** + * Redis configuration for distributed caching. + * If not provided, falls back to in-memory cache. + */ + redis?: { + /** Redis provider type */ + provider: 'redis' | 'ioredis' | 'vercel-kv' | '@vercel/kv'; + /** Redis host */ + host?: string; + /** Redis port */ + port?: number; + /** Redis password */ + password?: string; + /** Redis database number */ + db?: number; + }; + + /** + * Cache TTL in milliseconds. + * @default 60000 (1 minute) + */ + ttlMs?: number; + + /** + * Key prefix for Redis cache. + * @default 'frontmcp:skills:cache:' + */ + keyPrefix?: string; +} diff --git a/libs/sdk/src/common/types/options/skills-http/schema.ts b/libs/sdk/src/common/types/options/skills-http/schema.ts new file mode 100644 index 00000000..976d1208 --- /dev/null +++ b/libs/sdk/src/common/types/options/skills-http/schema.ts @@ -0,0 +1,156 @@ +// common/types/options/skills-http/schema.ts +// Zod schema for skills HTTP configuration + +import { z } from 'zod'; +import type { SkillsConfigAuthMode } from './interfaces'; + +/** + * Authentication mode schema for skills HTTP endpoints. + */ +export const skillsConfigAuthModeSchema = z.enum(['inherit', 'public', 'api-key', 'bearer']); + +/** + * Endpoint configuration schema (simplified - no auth, just enabled and path). + */ +export const skillsConfigEndpointConfigSchema = z.object({ + enabled: z.boolean().optional().default(true), + path: z.string().optional(), +}); + +/** + * Union type for endpoint configuration (config object or boolean). + */ +const skillsConfigEndpointInputSchema = z.union([skillsConfigEndpointConfigSchema, z.boolean()]); + +/** + * JWT validation configuration schema. + */ +export const skillsConfigJwtOptionsSchema = z.object({ + issuer: z.string().min(1), + audience: z.string().optional(), + jwksUrl: z.string().url().optional(), +}); + +/** + * Cache configuration schema. + */ +export const skillsConfigCacheOptionsSchema = z.object({ + enabled: z.boolean().optional().default(false), + redis: z + .object({ + provider: z.enum(['redis', 'ioredis', 'vercel-kv', '@vercel/kv']), + host: z.string().optional(), + port: z.number().int().positive().optional(), + password: z.string().optional(), + db: z.number().int().nonnegative().optional(), + }) + .optional(), + ttlMs: z.number().int().positive().optional().default(60000), + keyPrefix: z.string().optional(), +}); + +/** + * Skills HTTP options Zod schema. + * Auth is configured at the top level and applies to all HTTP endpoints. + */ +export const skillsConfigOptionsSchema = z.object({ + enabled: z.boolean().optional().default(false), + prefix: z.string().optional(), + auth: skillsConfigAuthModeSchema.optional().default('inherit'), + apiKeys: z.array(z.string().min(1)).optional(), + jwt: skillsConfigJwtOptionsSchema.optional(), + llmTxt: skillsConfigEndpointInputSchema.optional().default(true), + llmFullTxt: skillsConfigEndpointInputSchema.optional().default(true), + api: skillsConfigEndpointInputSchema.optional().default(true), + mcpTools: z.boolean().optional().default(true), + cache: skillsConfigCacheOptionsSchema.optional(), +}); + +/** + * Skills HTTP options type (with defaults applied). + */ +export type SkillsConfigOptions = z.infer; + +/** + * Skills HTTP options input type (for user configuration). + */ +export type SkillsConfigOptionsInput = z.input; + +/** + * Skills HTTP endpoint config type (with defaults applied). + */ +export type SkillsConfigEndpointConfig = z.infer; + +/** + * Skills HTTP endpoint config input type. + */ +export type SkillsConfigEndpointConfigInput = z.input; + +/** + * Normalized endpoint configuration with auth from parent. + * This is the result of normalizing an endpoint config with the parent's auth settings. + */ +export interface NormalizedEndpointConfig { + enabled: boolean; + path: string; + auth: SkillsConfigAuthMode; + apiKeys?: string[]; +} + +/** + * Normalize endpoint configuration. + * Converts boolean or config object to normalized config object. + * Auth settings come from the parent skillsConfig, not per-endpoint. + */ +export function normalizeEndpointConfig( + config: boolean | SkillsConfigEndpointConfig | undefined, + defaultPath: string, + parentAuth: SkillsConfigAuthMode, + parentApiKeys?: string[], +): NormalizedEndpointConfig { + if (config === undefined || config === true) { + return { + enabled: true, + path: defaultPath, + auth: parentAuth, + apiKeys: parentApiKeys, + }; + } + if (config === false) { + return { + enabled: false, + path: defaultPath, + auth: parentAuth, + apiKeys: parentApiKeys, + }; + } + return { + enabled: config.enabled !== false, + path: config.path ?? defaultPath, + auth: parentAuth, + apiKeys: parentApiKeys, + }; +} + +/** + * Normalize skills HTTP options. + * Applies defaults and normalizes all endpoint configurations. + * Auth is applied from the top level to all endpoints. + */ +export function normalizeSkillsConfigOptions(options: SkillsConfigOptionsInput | undefined): SkillsConfigOptions & { + normalizedLlmTxt: NormalizedEndpointConfig; + normalizedLlmFullTxt: NormalizedEndpointConfig; + normalizedApi: NormalizedEndpointConfig; +} { + const parsed = skillsConfigOptionsSchema.parse(options ?? {}); + const prefix = parsed.prefix ?? ''; + const auth = parsed.auth ?? 'inherit'; + const apiKeys = parsed.apiKeys; + + return { + ...parsed, + normalizedLlmTxt: normalizeEndpointConfig(parsed.llmTxt, `${prefix}/llm.txt`, auth, apiKeys), + normalizedLlmFullTxt: normalizeEndpointConfig(parsed.llmFullTxt, `${prefix}/llm_full.txt`, auth, apiKeys), + normalizedApi: normalizeEndpointConfig(parsed.api, `${prefix}/skills`, auth, apiKeys), + }; +} diff --git a/libs/sdk/src/scope/scope.instance.ts b/libs/sdk/src/scope/scope.instance.ts index 579c2e62..7bd4de3e 100644 --- a/libs/sdk/src/scope/scope.instance.ts +++ b/libs/sdk/src/scope/scope.instance.ts @@ -28,9 +28,8 @@ import HookRegistry from '../hooks/hook.registry'; import PromptRegistry from '../prompt/prompt.registry'; import AgentRegistry from '../agent/agent.registry'; import SkillRegistry from '../skill/skill.registry'; -import { SearchSkillsFlow, LoadSkillFlow } from '../skill/flows'; -import { getSkillTools } from '../skill/tools'; import { SkillValidationError } from '../skill/errors/skill-validation.error'; +import { registerSkillCapabilities } from '../skill/skill-scope.helper'; import { SkillSessionManager, createSkillSessionStore } from '../skill/session'; import { createSkillToolGuardHook } from '../skill/hooks'; import { normalizeHooksFromCls } from '../hooks/hooks.utils'; @@ -367,28 +366,15 @@ export class Scope extends ScopeEntry { } } - // Register skill flows if any skills are available - if (this.scopeSkills.hasAny()) { - await this.scopeFlows.registryFlows([SearchSkillsFlow, LoadSkillFlow]); - - // Register skill tools (searchSkills, loadSkill) - const skillTools = getSkillTools(); - for (const SkillToolClass of skillTools) { - try { - const toolRecord = normalizeTool(SkillToolClass); - const toolEntry = new ToolInstance(toolRecord, this.scopeProviders, { - kind: 'scope', - id: '_skills', - ref: SkillToolClass, - }); - await toolEntry.ready; - this.scopeTools.registerToolInstance(toolEntry); - this.logger.verbose(`Registered skill tool: ${toolRecord.metadata.name}`); - } catch (error) { - this.logger.warn(`Failed to register skill tool: ${error instanceof Error ? error.message : String(error)}`); - } - } - } + // Register skill flows and tools if any skills are available + await registerSkillCapabilities({ + skillRegistry: this.scopeSkills, + flowRegistry: this.scopeFlows, + toolRegistry: this.scopeTools, + providers: this.scopeProviders, + skillsConfig: this.metadata.skillsConfig, + logger: this.logger, + }); // Initialize notification service after all registries are ready this.notificationService = new NotificationService(this); diff --git a/libs/sdk/src/skill/__tests__/skill-http.utils.test.ts b/libs/sdk/src/skill/__tests__/skill-http.utils.test.ts new file mode 100644 index 00000000..fa99d321 --- /dev/null +++ b/libs/sdk/src/skill/__tests__/skill-http.utils.test.ts @@ -0,0 +1,386 @@ +// skill/__tests__/skill-http.utils.test.ts + +import { + formatSkillsForLlmCompact, + formatSkillForLLMWithSchemas, + skillToApiResponse, + filterSkillsByVisibility, +} from '../skill-http.utils'; +import type { SkillEntry } from '../../common'; +import type { SkillContent } from '../../common/interfaces'; + +// Mock SkillEntry for testing +function createMockSkillEntry( + overrides: Partial<{ + name: string; + metadata: Partial; + toolNames: string[]; + }>, +): SkillEntry { + const defaults = { + name: 'test-skill', + metadata: { + id: 'test-skill-id', + name: 'test-skill', + description: 'A test skill', + tags: ['test', 'mock'], + priority: 0, + visibility: 'both' as const, + parameters: [], + hideFromDiscovery: false, + toolValidation: 'warn' as const, + instructions: 'Test instructions', + }, + toolNames: ['tool1', 'tool2'], + }; + + const merged = { ...defaults, ...overrides }; + merged.metadata = { ...defaults.metadata, ...overrides.metadata }; + + return { + name: merged.name, + metadata: merged.metadata as SkillEntry['metadata'], + getToolNames: () => merged.toolNames, + isHidden: () => merged.metadata.hideFromDiscovery ?? false, + } as unknown as SkillEntry; +} + +// Mock SkillContent for testing +function createMockSkillContent(overrides: Partial = {}): SkillContent { + return { + id: 'test-skill-id', + name: 'test-skill', + description: 'A test skill description', + instructions: 'Step 1: Do this\nStep 2: Do that', + tools: [ + { name: 'tool1', purpose: 'First tool purpose' }, + { name: 'tool2', purpose: 'Second tool purpose' }, + ], + parameters: [{ name: 'param1', description: 'First parameter', required: true, type: 'string' }], + examples: [{ scenario: 'Example scenario', expectedOutcome: 'Expected result' }], + ...overrides, + }; +} + +// Mock ToolRegistryInterface for testing +function createMockToolRegistry( + tools: Array<{ + name: string; + rawInputSchema?: unknown; + rawOutputSchema?: unknown; + }>, +) { + return { + getTools: (includeHidden?: boolean) => + tools.map((t) => ({ + name: t.name, + rawInputSchema: t.rawInputSchema, + rawOutputSchema: t.rawOutputSchema, + getRawOutputSchema: () => t.rawOutputSchema, + getInputJsonSchema: () => t.rawInputSchema ?? null, + })), + }; +} + +describe('formatSkillsForLlmCompact', () => { + it('should format a single skill correctly', () => { + const skills = [ + createMockSkillEntry({ + name: 'review-pr', + metadata: { + name: 'review-pr', + description: 'Review a pull request', + tags: ['github', 'code-review'], + }, + toolNames: ['github_get_pr', 'github_add_comment'], + }), + ]; + + const result = formatSkillsForLlmCompact(skills); + + expect(result).toContain('# review-pr'); + expect(result).toContain('Review a pull request'); + expect(result).toContain('Tools: github_get_pr, github_add_comment'); + expect(result).toContain('Tags: github, code-review'); + }); + + it('should format multiple skills with separator', () => { + const skills = [ + createMockSkillEntry({ + name: 'skill1', + metadata: { name: 'skill1', description: 'First skill' }, + }), + createMockSkillEntry({ + name: 'skill2', + metadata: { name: 'skill2', description: 'Second skill' }, + }), + ]; + + const result = formatSkillsForLlmCompact(skills); + + expect(result).toContain('# skill1'); + expect(result).toContain('# skill2'); + expect(result).toContain('---'); + }); + + it('should handle skills without tools', () => { + const skills = [ + createMockSkillEntry({ + name: 'no-tools', + metadata: { name: 'no-tools', description: 'Skill without tools' }, + toolNames: [], + }), + ]; + + const result = formatSkillsForLlmCompact(skills); + + expect(result).toContain('# no-tools'); + expect(result).not.toContain('Tools:'); + }); + + it('should handle skills without tags', () => { + const skills = [ + createMockSkillEntry({ + name: 'no-tags', + metadata: { name: 'no-tags', description: 'Skill without tags', tags: [] }, + }), + ]; + + const result = formatSkillsForLlmCompact(skills); + + expect(result).toContain('# no-tags'); + expect(result).not.toContain('Tags:'); + }); + + it('should return empty string for empty skills array', () => { + const result = formatSkillsForLlmCompact([]); + expect(result).toBe(''); + }); +}); + +describe('formatSkillForLLMWithSchemas', () => { + it('should format skill with available tools and schemas', () => { + const skill = createMockSkillContent(); + const availableTools = ['tool1', 'tool2']; + const missingTools: string[] = []; + const toolRegistry = createMockToolRegistry([ + { + name: 'tool1', + rawInputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + rawOutputSchema: { type: 'object', properties: { output: { type: 'string' } } }, + }, + { + name: 'tool2', + rawInputSchema: { type: 'object', properties: { data: { type: 'number' } } }, + }, + ]); + + const result = formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry as any); + + expect(result).toContain('# Skill: test-skill'); + expect(result).toContain('A test skill description'); + expect(result).toContain('## Tools'); + expect(result).toContain('[✓] tool1'); + expect(result).toContain('[✓] tool2'); + expect(result).toContain('**Input Schema:**'); + expect(result).toContain('"type": "object"'); + expect(result).toContain('## Instructions'); + expect(result).toContain('Step 1: Do this'); + }); + + it('should show warning for missing tools', () => { + const skill = createMockSkillContent(); + const availableTools = ['tool1']; + const missingTools = ['tool2']; + const toolRegistry = createMockToolRegistry([{ name: 'tool1', rawInputSchema: { type: 'object' } }]); + + const result = formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry as any); + + expect(result).toContain('**Warning:**'); + expect(result).toContain('Missing: tool2'); + expect(result).toContain('[✓] tool1'); + expect(result).toContain('[✗] tool2'); + }); + + it('should include parameters section', () => { + const skill = createMockSkillContent({ + parameters: [ + { name: 'repo', description: 'Repository name', required: true, type: 'string' }, + { name: 'branch', description: 'Branch name', required: false, type: 'string' }, + ], + }); + const toolRegistry = createMockToolRegistry([]); + + const result = formatSkillForLLMWithSchemas(skill, [], [], toolRegistry as any); + + expect(result).toContain('## Parameters'); + expect(result).toContain('**repo** (required)'); + expect(result).toContain('**branch**'); + }); + + it('should include examples section', () => { + const skill = createMockSkillContent({ + examples: [{ scenario: 'Review a simple PR', expectedOutcome: 'PR is reviewed with comments' }], + }); + const toolRegistry = createMockToolRegistry([]); + + const result = formatSkillForLLMWithSchemas(skill, [], [], toolRegistry as any); + + expect(result).toContain('## Examples'); + expect(result).toContain('### Review a simple PR'); + expect(result).toContain('Expected outcome: PR is reviewed with comments'); + }); + + it('should handle skill without parameters', () => { + const skill = createMockSkillContent({ parameters: undefined }); + const toolRegistry = createMockToolRegistry([]); + + const result = formatSkillForLLMWithSchemas(skill, [], [], toolRegistry as any); + + expect(result).not.toContain('## Parameters'); + }); + + it('should handle skill without examples', () => { + const skill = createMockSkillContent({ examples: undefined }); + const toolRegistry = createMockToolRegistry([]); + + const result = formatSkillForLLMWithSchemas(skill, [], [], toolRegistry as any); + + expect(result).not.toContain('## Examples'); + }); +}); + +describe('skillToApiResponse', () => { + it('should convert skill entry to API response', () => { + const skill = createMockSkillEntry({ + name: 'test-skill', + metadata: { + id: 'skill-123', + name: 'test-skill', + description: 'A test skill', + tags: ['test'], + priority: 5, + visibility: 'both', + parameters: [{ name: 'param1', description: 'First param', required: true, type: 'string' }], + }, + toolNames: ['tool1'], + }); + + const result = skillToApiResponse(skill); + + expect(result.id).toBe('skill-123'); + expect(result.name).toBe('test-skill'); + expect(result.description).toBe('A test skill'); + expect(result.tags).toEqual(['test']); + expect(result.tools).toEqual(['tool1']); + expect(result.priority).toBe(5); + expect(result.visibility).toBe('both'); + expect(result.parameters).toHaveLength(1); + expect(result.parameters?.[0].name).toBe('param1'); + }); + + it('should use name as id when id is not set', () => { + const skill = createMockSkillEntry({ + name: 'my-skill', + metadata: { id: undefined, name: 'my-skill', description: 'desc' }, + }); + + const result = skillToApiResponse(skill); + + expect(result.id).toBe('my-skill'); + }); + + it('should include load result info when provided', () => { + const skill = createMockSkillEntry({}); + const loadResult = { + availableTools: ['tool1'], + missingTools: ['tool2'], + isComplete: false, + }; + + const result = skillToApiResponse(skill, loadResult); + + expect(result.availableTools).toEqual(['tool1']); + expect(result.missingTools).toEqual(['tool2']); + expect(result.isComplete).toBe(false); + }); + + it('should default visibility to both', () => { + const skill = createMockSkillEntry({ + metadata: { visibility: undefined }, + }); + + const result = skillToApiResponse(skill); + + expect(result.visibility).toBe('both'); + }); + + it('should handle empty tags', () => { + const skill = createMockSkillEntry({ + metadata: { tags: undefined }, + }); + + const result = skillToApiResponse(skill); + + expect(result.tags).toEqual([]); + }); +}); + +describe('filterSkillsByVisibility', () => { + it('should filter skills for MCP context', () => { + const skills = [ + createMockSkillEntry({ metadata: { visibility: 'mcp' } }), + createMockSkillEntry({ metadata: { visibility: 'http' } }), + createMockSkillEntry({ metadata: { visibility: 'both' } }), + ]; + + const result = filterSkillsByVisibility(skills, 'mcp'); + + expect(result).toHaveLength(2); + expect(result.map((s) => s.metadata.visibility)).toEqual(['mcp', 'both']); + }); + + it('should filter skills for HTTP context', () => { + const skills = [ + createMockSkillEntry({ metadata: { visibility: 'mcp' } }), + createMockSkillEntry({ metadata: { visibility: 'http' } }), + createMockSkillEntry({ metadata: { visibility: 'both' } }), + ]; + + const result = filterSkillsByVisibility(skills, 'http'); + + expect(result).toHaveLength(2); + expect(result.map((s) => s.metadata.visibility)).toEqual(['http', 'both']); + }); + + it('should include all skills with both visibility in any context', () => { + const skills = [ + createMockSkillEntry({ name: 'skill1', metadata: { visibility: 'both' } }), + createMockSkillEntry({ name: 'skill2', metadata: { visibility: 'both' } }), + ]; + + const mcpResult = filterSkillsByVisibility(skills, 'mcp'); + const httpResult = filterSkillsByVisibility(skills, 'http'); + + expect(mcpResult).toHaveLength(2); + expect(httpResult).toHaveLength(2); + }); + + it('should default undefined visibility to both', () => { + const skills = [createMockSkillEntry({ metadata: { visibility: undefined } })]; + + const mcpResult = filterSkillsByVisibility(skills, 'mcp'); + const httpResult = filterSkillsByVisibility(skills, 'http'); + + expect(mcpResult).toHaveLength(1); + expect(httpResult).toHaveLength(1); + }); + + it('should return empty array when no skills match', () => { + const skills = [createMockSkillEntry({ metadata: { visibility: 'mcp' } })]; + + const result = filterSkillsByVisibility(skills, 'http'); + + expect(result).toHaveLength(0); + }); +}); diff --git a/libs/sdk/src/skill/auth/index.ts b/libs/sdk/src/skill/auth/index.ts new file mode 100644 index 00000000..05d0b0ef --- /dev/null +++ b/libs/sdk/src/skill/auth/index.ts @@ -0,0 +1,12 @@ +// file: libs/sdk/src/skill/auth/index.ts + +/** + * Skill HTTP Authentication + * + * Provides authentication validation for skills HTTP endpoints. + * + * @module skill/auth + */ + +export { SkillHttpAuthValidator, createSkillHttpAuthValidator } from './skill-http-auth'; +export type { SkillHttpAuthContext, SkillHttpAuthResult, SkillHttpAuthValidatorOptions } from './skill-http-auth'; diff --git a/libs/sdk/src/skill/auth/skill-http-auth.ts b/libs/sdk/src/skill/auth/skill-http-auth.ts new file mode 100644 index 00000000..bdfe5935 --- /dev/null +++ b/libs/sdk/src/skill/auth/skill-http-auth.ts @@ -0,0 +1,238 @@ +// file: libs/sdk/src/skill/auth/skill-http-auth.ts + +/** + * Authentication validation for skills HTTP endpoints. + * + * Supports multiple authentication modes: + * - public: No authentication required + * - api-key: API key in X-API-Key header or Authorization: ApiKey + * - bearer: JWT token validated against configured issuer using JWKS + * + * @module skill/auth/skill-http-auth + */ + +import type { FrontMcpLogger } from '../../common'; +import type { SkillsConfigOptions } from '../../common/types/options/skills-http'; + +/** + * Request context for auth validation. + */ +export interface SkillHttpAuthContext { + /** Request headers (lowercase keys) */ + headers: Record; +} + +/** + * Result of auth validation. + */ +export interface SkillHttpAuthResult { + /** Whether the request is authorized */ + authorized: boolean; + /** Error message if not authorized */ + error?: string; + /** HTTP status code for the error response */ + statusCode?: number; +} + +/** + * Options for creating SkillHttpAuthValidator. + */ +export interface SkillHttpAuthValidatorOptions { + /** Skills configuration with auth settings */ + skillsConfig: SkillsConfigOptions; + /** Optional logger for debugging */ + logger?: FrontMcpLogger; +} + +/** + * Validator for skills HTTP endpoint authentication. + * + * Implements authentication validation based on SkillsConfigAuthMode: + * - public: No validation, all requests pass + * - api-key: Validates API key from X-API-Key header or Authorization: ApiKey + * - bearer: Validates JWT token using JWKS from configured issuer + * + * @example + * ```typescript + * const validator = new SkillHttpAuthValidator({ + * skillsConfig: { auth: 'api-key', apiKeys: ['sk-xxx'] }, + * logger, + * }); + * + * const result = await validator.validate({ headers: req.headers }); + * if (!result.authorized) { + * res.status(result.statusCode ?? 401).json({ error: result.error }); + * return; + * } + * ``` + */ +export class SkillHttpAuthValidator { + private readonly skillsConfig: SkillsConfigOptions; + private readonly logger?: FrontMcpLogger; + + constructor(options: SkillHttpAuthValidatorOptions) { + this.skillsConfig = options.skillsConfig; + this.logger = options.logger; + } + + /** + * Validate auth for a request. + * + * @param ctx - Request context with headers + * @returns Auth result with authorized flag and optional error + */ + async validate(ctx: SkillHttpAuthContext): Promise { + const mode = this.skillsConfig.auth ?? 'inherit'; + + switch (mode) { + case 'public': + return { authorized: true }; + + case 'inherit': + // inherit means use server's default auth - flows handle this + return { authorized: true }; + + case 'api-key': + return this.validateApiKey(ctx); + + case 'bearer': + return this.validateBearer(ctx); + + default: + // Unknown mode - default to allowed (inherit behavior) + return { authorized: true }; + } + } + + /** + * Validate API key authentication. + * + * Accepts API key in: + * - X-API-Key header + * - Authorization header as `ApiKey ` + */ + private validateApiKey(ctx: SkillHttpAuthContext): SkillHttpAuthResult { + const apiKeys = this.skillsConfig.apiKeys ?? []; + + if (apiKeys.length === 0) { + this.logger?.error('api-key auth mode requires apiKeys to be configured'); + return { + authorized: false, + error: 'Server misconfiguration', + statusCode: 500, + }; + } + + // Get header values (case-insensitive) + const authHeader = this.getHeader(ctx.headers, 'authorization'); + const apiKeyHeader = this.getHeader(ctx.headers, 'x-api-key'); + + // Check X-API-Key header first + if (apiKeyHeader && apiKeys.includes(apiKeyHeader)) { + return { authorized: true }; + } + + // Check Authorization: ApiKey format + if (authHeader?.startsWith('ApiKey ')) { + const key = authHeader.slice(7); + if (apiKeys.includes(key)) { + return { authorized: true }; + } + } + + return { + authorized: false, + error: 'Invalid or missing API key', + statusCode: 401, + }; + } + + /** + * Validate Bearer token (JWT) authentication. + * + * Uses JWKS from the configured issuer to validate the JWT. + * Validates issuer and optionally audience claims. + */ + private async validateBearer(ctx: SkillHttpAuthContext): Promise { + const jwtConfig = this.skillsConfig.jwt; + + if (!jwtConfig?.issuer) { + this.logger?.error('bearer auth mode requires jwt.issuer to be configured'); + return { + authorized: false, + error: 'Server misconfiguration', + statusCode: 500, + }; + } + + const authHeader = this.getHeader(ctx.headers, 'authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return { + authorized: false, + error: 'Missing Bearer token', + statusCode: 401, + }; + } + + const token = authHeader.slice(7); + + try { + // Lazy import jose to avoid bundling when not used + const { jwtVerify, createRemoteJWKSet } = await import('jose'); + + const jwksUrl = jwtConfig.jwksUrl ?? `${jwtConfig.issuer}/.well-known/jwks.json`; + const JWKS = createRemoteJWKSet(new URL(jwksUrl)); + + const { payload } = await jwtVerify(token, JWKS, { + issuer: jwtConfig.issuer, + audience: jwtConfig.audience, + }); + + this.logger?.verbose('JWT validated successfully', { sub: payload.sub }); + return { authorized: true }; + } catch (error) { + this.logger?.warn('JWT validation failed', { + error: error instanceof Error ? error.message : String(error), + }); + + return { + authorized: false, + error: 'Invalid JWT token', + statusCode: 401, + }; + } + } + + /** + * Get a header value from the headers object. + * Handles both string and string[] values. + */ + private getHeader(headers: Record, name: string): string | undefined { + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; + } +} + +/** + * Create a skill HTTP auth validator from config. + * + * Returns null if no validation is needed (public or inherit mode). + * + * @param skillsConfig - Skills configuration + * @param logger - Optional logger + * @returns Validator instance or null + */ +export function createSkillHttpAuthValidator( + skillsConfig: SkillsConfigOptions | undefined, + logger?: FrontMcpLogger, +): SkillHttpAuthValidator | null { + if (!skillsConfig?.auth || skillsConfig.auth === 'public' || skillsConfig.auth === 'inherit') { + return null; // No validation needed + } + + return new SkillHttpAuthValidator({ skillsConfig, logger }); +} diff --git a/libs/sdk/src/skill/cache/index.ts b/libs/sdk/src/skill/cache/index.ts new file mode 100644 index 00000000..06a39bc1 --- /dev/null +++ b/libs/sdk/src/skill/cache/index.ts @@ -0,0 +1,24 @@ +// file: libs/sdk/src/skill/cache/index.ts + +/** + * Skill HTTP Caching + * + * Provides caching for skills HTTP endpoints to reduce latency + * and resource usage for repeated requests. + * + * @module skill/cache + */ + +export { SkillHttpCache, MemorySkillHttpCache, RedisSkillHttpCache } from './skill-http-cache.js'; +export { createSkillHttpCache } from './skill-http-cache.factory.js'; +export type { + SkillHttpCacheOptions, + SkillHttpCacheResult, + SkillHttpCacheRedisOptions, +} from './skill-http-cache.factory.js'; +export { + getSkillHttpCache, + invalidateScopeCache, + invalidateSkillInCache, + disposeAllCaches, +} from './skill-http-cache.holder.js'; diff --git a/libs/sdk/src/skill/cache/skill-http-cache.factory.ts b/libs/sdk/src/skill/cache/skill-http-cache.factory.ts new file mode 100644 index 00000000..bb0a9b53 --- /dev/null +++ b/libs/sdk/src/skill/cache/skill-http-cache.factory.ts @@ -0,0 +1,169 @@ +// file: libs/sdk/src/skill/cache/skill-http-cache.factory.ts + +/** + * Factory for creating skill HTTP cache instances. + * + * @module skill/cache/skill-http-cache.factory + */ + +import type { FrontMcpLogger } from '../../common/index.js'; +import { SkillHttpCache, MemorySkillHttpCache, RedisSkillHttpCache } from './skill-http-cache.js'; + +/** + * Redis configuration options for the cache. + */ +export interface SkillHttpCacheRedisOptions { + /** Redis provider type */ + provider: 'redis' | 'ioredis' | 'vercel-kv' | '@vercel/kv'; + /** Redis host */ + host?: string; + /** Redis port */ + port?: number; + /** Redis password */ + password?: string; + /** Redis database number */ + db?: number; +} + +/** + * Options for creating a skill HTTP cache. + */ +export interface SkillHttpCacheOptions { + /** + * Redis configuration for distributed caching. + * If not provided, falls back to memory cache. + */ + redis?: SkillHttpCacheRedisOptions; + + /** + * Cache TTL in milliseconds. + * @default 60000 (1 minute) + */ + ttlMs?: number; + + /** + * Key prefix for Redis cache. + * @default 'frontmcp:skills:cache:' + */ + keyPrefix?: string; + + /** + * Optional logger for cache operations. + */ + logger?: FrontMcpLogger; +} + +/** + * Result of creating a skill HTTP cache. + */ +export interface SkillHttpCacheResult { + /** The cache instance */ + cache: SkillHttpCache; + /** The cache type that was created */ + type: 'memory' | 'redis'; +} + +/** + * Check if the provider is a Redis provider. + */ +function hasRedisProvider(redis: SkillHttpCacheRedisOptions | undefined): boolean { + if (!redis?.provider) return false; + const provider = redis.provider; + return provider === 'redis' || provider === 'ioredis' || provider === 'vercel-kv' || provider === '@vercel/kv'; +} + +/** + * Create a skill HTTP cache from configuration. + * + * If Redis configuration is provided, creates a Redis-backed cache. + * Otherwise, falls back to an in-memory cache. + * + * @param options - Cache configuration + * @returns Cache instance and type + * + * @example Memory cache (default) + * ```typescript + * const { cache, type } = await createSkillHttpCache({ ttlMs: 30000 }); + * // type === 'memory' + * ``` + * + * @example Redis cache + * ```typescript + * const { cache, type } = await createSkillHttpCache({ + * redis: { provider: 'redis', host: 'localhost', port: 6379 }, + * ttlMs: 60000, + * }); + * // type === 'redis' + * ``` + */ +export async function createSkillHttpCache(options: SkillHttpCacheOptions = {}): Promise { + const ttlMs = options.ttlMs ?? 60000; + const keyPrefix = options.keyPrefix ?? 'frontmcp:skills:cache:'; + const logger = options.logger; + + // Check if Redis is configured + if (hasRedisProvider(options.redis)) { + try { + // Lazy-load Redis client factory + // Note: This assumes a createRedisClient exists in common/redis + // For now, we'll create a simple client wrapper + const cache = await createRedisCache(options.redis!, keyPrefix, ttlMs, logger); + logger?.verbose('Created Redis-backed skill HTTP cache', { keyPrefix, ttlMs }); + return { cache, type: 'redis' }; + } catch (error) { + logger?.warn('Failed to create Redis cache, falling back to memory', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Fall back to memory cache + const cache = new MemorySkillHttpCache(ttlMs); + logger?.verbose('Created memory-backed skill HTTP cache', { ttlMs }); + return { cache, type: 'memory' }; +} + +/** + * Create a Redis-backed cache. + */ +async function createRedisCache( + redis: SkillHttpCacheRedisOptions, + keyPrefix: string, + ttlMs: number, + logger?: FrontMcpLogger, +): Promise { + // Create a Redis client based on provider + const provider = redis.provider; + + if (provider === 'vercel-kv' || provider === '@vercel/kv') { + // Lazy-load Vercel KV - use require for CommonJS compatibility + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { kv } = require('@vercel/kv'); + return new RedisSkillHttpCache({ + getClient: async () => kv, + keyPrefix, + ttlMs, + }); + } + + // Default to ioredis - use require for CommonJS compatibility + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Redis = require('ioredis'); + const client = new Redis({ + host: redis.host ?? 'localhost', + port: redis.port ?? 6379, + password: redis.password, + db: redis.db, + lazyConnect: true, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + }); + + await client.connect(); + + return new RedisSkillHttpCache({ + getClient: async () => client, + keyPrefix, + ttlMs, + }); +} diff --git a/libs/sdk/src/skill/cache/skill-http-cache.holder.ts b/libs/sdk/src/skill/cache/skill-http-cache.holder.ts new file mode 100644 index 00000000..8782c77f --- /dev/null +++ b/libs/sdk/src/skill/cache/skill-http-cache.holder.ts @@ -0,0 +1,136 @@ +// file: libs/sdk/src/skill/cache/skill-http-cache.holder.ts + +/** + * Singleton holder for skill HTTP cache instances. + * + * This provides a way to share cache instances across flows + * without requiring full scope integration. + * + * @module skill/cache/skill-http-cache.holder + */ + +import type { ScopeEntry } from '../../common/index.js'; +import { SkillHttpCache, MemorySkillHttpCache } from './skill-http-cache.js'; +import { createSkillHttpCache } from './skill-http-cache.factory.js'; + +/** + * Cache holder keyed by scope ID. + * Allows each scope to have its own cache instance. + */ +const cacheByScope = new Map(); + +/** + * Pending cache creation promises to avoid race conditions. + */ +const pendingCreation = new Map>(); + +/** + * Get or create a skill HTTP cache for a scope. + * + * @param scope - The scope entry + * @returns Cache instance (may be shared with other flows in same scope) + */ +export async function getSkillHttpCache(scope: ScopeEntry): Promise { + const skillsConfig = scope.metadata.skillsConfig; + const cacheConfig = skillsConfig?.cache; + + // Return null if caching is disabled + if (!cacheConfig?.enabled) { + return null; + } + + const scopeId = scope.metadata.id; + + // Check if cache already exists + const existing = cacheByScope.get(scopeId); + if (existing) { + return existing; + } + + // Check if creation is in progress + const pending = pendingCreation.get(scopeId); + if (pending) { + return pending; + } + + // Create cache + const creationPromise = createCacheForScope(scopeId, cacheConfig); + pendingCreation.set(scopeId, creationPromise); + + try { + const cache = await creationPromise; + cacheByScope.set(scopeId, cache); + return cache; + } finally { + pendingCreation.delete(scopeId); + } +} + +/** + * Cache configuration type from SkillsConfigOptions. + */ +interface CacheConfig { + enabled?: boolean; + redis?: { + provider: 'redis' | 'ioredis' | 'vercel-kv' | '@vercel/kv'; + host?: string; + port?: number; + password?: string; + db?: number; + }; + ttlMs?: number; + keyPrefix?: string; +} + +/** + * Create a cache instance for a scope. + */ +async function createCacheForScope(scopeId: string, cacheConfig: CacheConfig): Promise { + try { + const { cache } = await createSkillHttpCache({ + redis: cacheConfig.redis, + ttlMs: cacheConfig.ttlMs, + keyPrefix: cacheConfig.keyPrefix ?? `frontmcp:skills:${scopeId}:cache:`, + }); + return cache; + } catch { + // Fall back to memory cache on error + return new MemorySkillHttpCache(cacheConfig.ttlMs ?? 60000); + } +} + +/** + * Invalidate cache for a scope. + * + * @param scopeId - Scope identifier + */ +export async function invalidateScopeCache(scopeId: string): Promise { + const cache = cacheByScope.get(scopeId); + if (cache) { + await cache.invalidateAll(); + } +} + +/** + * Invalidate a specific skill in a scope's cache. + * + * @param scopeId - Scope identifier + * @param skillId - Skill identifier + */ +export async function invalidateSkillInCache(scopeId: string, skillId: string): Promise { + const cache = cacheByScope.get(scopeId); + if (cache) { + await cache.invalidateSkill(skillId); + } +} + +/** + * Dispose all caches (for testing/cleanup). + */ +export async function disposeAllCaches(): Promise { + for (const cache of cacheByScope.values()) { + await cache.dispose(); + } + cacheByScope.clear(); + pendingCreation.clear(); +} diff --git a/libs/sdk/src/skill/cache/skill-http-cache.ts b/libs/sdk/src/skill/cache/skill-http-cache.ts new file mode 100644 index 00000000..60ca9d01 --- /dev/null +++ b/libs/sdk/src/skill/cache/skill-http-cache.ts @@ -0,0 +1,318 @@ +// file: libs/sdk/src/skill/cache/skill-http-cache.ts + +/** + * Caching for skills HTTP endpoints. + * + * Provides a cache abstraction for skill HTTP responses to reduce + * latency and CPU overhead for repeated requests. + * + * Supports: + * - Memory cache (default, single-instance) + * - Redis cache (distributed, multi-instance) + * + * @module skill/cache/skill-http-cache + */ + +import type { SkillMetadata } from '../../common/metadata'; + +/** + * Cache interface for skills HTTP endpoints. + */ +export interface SkillHttpCache { + /** Cache type identifier */ + readonly type: 'memory' | 'redis'; + + /** + * Get cached /llm.txt content. + * @returns Cached content or null if not cached/expired + */ + getLlmTxt(): Promise; + + /** + * Set cached /llm.txt content. + * @param content - Content to cache + */ + setLlmTxt(content: string): Promise; + + /** + * Get cached /llm_full.txt content. + * @returns Cached content or null if not cached/expired + */ + getLlmFullTxt(): Promise; + + /** + * Set cached /llm_full.txt content. + * @param content - Content to cache + */ + setLlmFullTxt(content: string): Promise; + + /** + * Get cached skills list. + * @param hash - Hash of filter parameters + * @returns Cached skills or null if not cached/expired + */ + getSkillsList(hash: string): Promise; + + /** + * Set cached skills list. + * @param hash - Hash of filter parameters + * @param skills - Skills to cache + */ + setSkillsList(hash: string, skills: SkillMetadata[]): Promise; + + /** + * Get cached individual skill. + * @param skillId - Skill identifier + * @returns Cached skill data or null if not cached/expired + */ + getSkill(skillId: string): Promise; + + /** + * Set cached individual skill. + * @param skillId - Skill identifier + * @param data - Skill data to cache + */ + setSkill(skillId: string, data: unknown): Promise; + + /** + * Invalidate all cached data. + */ + invalidateAll(): Promise; + + /** + * Invalidate cached data for a specific skill. + * @param skillId - Skill identifier + */ + invalidateSkill(skillId: string): Promise; + + /** + * Dispose of cache resources. + */ + dispose(): Promise; +} + +/** + * Cache entry with expiration. + */ +interface CacheEntry { + data: T; + expiresAt: number; +} + +/** + * In-memory implementation of SkillHttpCache. + * + * Suitable for single-instance deployments. + * Data is lost on process restart. + */ +export class MemorySkillHttpCache implements SkillHttpCache { + readonly type = 'memory' as const; + + private readonly cache = new Map>(); + private readonly ttlMs: number; + + constructor(ttlMs = 60000) { + this.ttlMs = ttlMs; + } + + async getLlmTxt(): Promise { + return this.get('llm.txt'); + } + + async setLlmTxt(content: string): Promise { + this.set('llm.txt', content); + } + + async getLlmFullTxt(): Promise { + return this.get('llm_full.txt'); + } + + async setLlmFullTxt(content: string): Promise { + this.set('llm_full.txt', content); + } + + async getSkillsList(hash: string): Promise { + return this.get(`list:${hash}`); + } + + async setSkillsList(hash: string, skills: SkillMetadata[]): Promise { + this.set(`list:${hash}`, skills); + } + + async getSkill(skillId: string): Promise { + return this.get(`skill:${skillId}`); + } + + async setSkill(skillId: string, data: unknown): Promise { + this.set(`skill:${skillId}`, data); + } + + async invalidateAll(): Promise { + this.cache.clear(); + } + + async invalidateSkill(skillId: string): Promise { + // Invalidate specific skill + this.cache.delete(`skill:${skillId}`); + + // Also invalidate list caches since they may contain this skill + for (const key of this.cache.keys()) { + if (key.startsWith('list:')) { + this.cache.delete(key); + } + } + + // Invalidate llm.txt and llm_full.txt since they include all skills + this.cache.delete('llm.txt'); + this.cache.delete('llm_full.txt'); + } + + async dispose(): Promise { + this.cache.clear(); + } + + private get(key: string): T | null { + const entry = this.cache.get(key) as CacheEntry | undefined; + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + + return entry.data; + } + + private set(key: string, data: unknown): void { + this.cache.set(key, { + data, + expiresAt: Date.now() + this.ttlMs, + }); + } +} + +/** + * Redis implementation of SkillHttpCache. + * + * Suitable for distributed/multi-instance deployments. + * Persists across process restarts (within TTL). + */ +export class RedisSkillHttpCache implements SkillHttpCache { + readonly type = 'redis' as const; + + private readonly keyPrefix: string; + private readonly ttlMs: number; + private readonly getClient: () => Promise; + + constructor(options: { getClient: () => Promise; keyPrefix?: string; ttlMs?: number }) { + this.getClient = options.getClient; + this.keyPrefix = options.keyPrefix ?? 'frontmcp:skills:cache:'; + this.ttlMs = options.ttlMs ?? 60000; + } + + async getLlmTxt(): Promise { + return this.get('llm.txt'); + } + + async setLlmTxt(content: string): Promise { + await this.set('llm.txt', content); + } + + async getLlmFullTxt(): Promise { + return this.get('llm_full.txt'); + } + + async setLlmFullTxt(content: string): Promise { + await this.set('llm_full.txt', content); + } + + async getSkillsList(hash: string): Promise { + return this.get(`list:${hash}`); + } + + async setSkillsList(hash: string, skills: SkillMetadata[]): Promise { + await this.set(`list:${hash}`, skills); + } + + async getSkill(skillId: string): Promise { + return this.get(`skill:${skillId}`); + } + + async setSkill(skillId: string, data: unknown): Promise { + await this.set(`skill:${skillId}`, data); + } + + async invalidateAll(): Promise { + try { + const client = await this.getClient(); + const keys = await client.keys(`${this.keyPrefix}*`); + if (keys.length > 0) { + await client.del(...keys); + } + } catch { + // Ignore errors during invalidation + } + } + + async invalidateSkill(skillId: string): Promise { + try { + const client = await this.getClient(); + + // Get all list keys and the specific skill key + const keysToDelete = [ + `${this.keyPrefix}skill:${skillId}`, + `${this.keyPrefix}llm.txt`, + `${this.keyPrefix}llm_full.txt`, + ]; + + // Also find and delete list caches + const listKeys = await client.keys(`${this.keyPrefix}list:*`); + keysToDelete.push(...listKeys); + + if (keysToDelete.length > 0) { + await client.del(...keysToDelete); + } + } catch { + // Ignore errors during invalidation + } + } + + async dispose(): Promise { + // Redis client lifecycle is managed externally + } + + private async get(key: string): Promise { + try { + const client = await this.getClient(); + const value = await client.get(`${this.keyPrefix}${key}`); + if (!value) return null; + + return JSON.parse(value) as T; + } catch { + return null; + } + } + + private async set(key: string, data: unknown): Promise { + try { + const client = await this.getClient(); + const serialized = JSON.stringify(data); + const ttlSeconds = Math.ceil(this.ttlMs / 1000); + + await client.setex(`${this.keyPrefix}${key}`, ttlSeconds, serialized); + } catch { + // Ignore errors during cache set + } + } +} + +/** + * Minimal Redis client interface used by the cache. + * Compatible with ioredis and @vercel/kv. + */ +interface RedisClient { + get(key: string): Promise; + setex(key: string, seconds: number, value: string): Promise; + del(...keys: string[]): Promise; + keys(pattern: string): Promise; +} diff --git a/libs/sdk/src/skill/flows/http/index.ts b/libs/sdk/src/skill/flows/http/index.ts new file mode 100644 index 00000000..d37532ef --- /dev/null +++ b/libs/sdk/src/skill/flows/http/index.ts @@ -0,0 +1,14 @@ +// file: libs/sdk/src/skill/flows/http/index.ts + +/** + * Skills HTTP Flows + * + * HTTP endpoints for skill discovery and loading. + * These flows are conditionally registered when skillsConfig.enabled is true. + * + * @module skill/flows/http + */ + +export { default as LlmTxtFlow } from './llm-txt.flow'; +export { default as LlmFullTxtFlow } from './llm-full-txt.flow'; +export { default as SkillsApiFlow } from './skills-api.flow'; diff --git a/libs/sdk/src/skill/flows/http/llm-full-txt.flow.ts b/libs/sdk/src/skill/flows/http/llm-full-txt.flow.ts new file mode 100644 index 00000000..2d0b9d50 --- /dev/null +++ b/libs/sdk/src/skill/flows/http/llm-full-txt.flow.ts @@ -0,0 +1,196 @@ +// file: libs/sdk/src/skill/flows/http/llm-full-txt.flow.ts + +/** + * HTTP flow for GET /llm_full.txt endpoint. + * Returns full skill content with instructions and tool schemas. + */ + +import { + Flow, + FlowBase, + FlowPlan, + FlowRunOptions, + httpInputSchema, + HttpTextSchema, + httpRespond, + ScopeEntry, + ServerRequest, + FlowHooksOf, + normalizeEntryPrefix, + normalizeScopeBase, +} from '../../../common'; +import { z } from 'zod'; +import { formatSkillsForLlmFull } from '../../skill-http.utils'; +import { normalizeSkillsConfigOptions } from '../../../common/types/options/skills-http'; +import { createSkillHttpAuthValidator } from '../../auth'; +import { getSkillHttpCache } from '../../cache'; + +const inputSchema = httpInputSchema; + +const stateSchema = z.object({ + prefix: z.string(), +}); + +const outputSchema = HttpTextSchema; + +const plan = { + pre: ['checkEnabled'], + execute: ['generateContent'], +} as const satisfies FlowPlan; + +declare global { + interface ExtendFlows { + 'skills-http:llm-full-txt': FlowRunOptions< + LlmFullTxtFlow, + typeof plan, + typeof inputSchema, + typeof outputSchema, + typeof stateSchema + >; + } +} + +const name = 'skills-http:llm-full-txt' as const; +const { Stage } = FlowHooksOf<'skills-http:llm-full-txt'>(name); + +/** + * Flow for serving full skill content at /llm_full.txt. + * + * This endpoint provides complete skill information including: + * - Full instructions + * - Complete tool schemas (input/output) + * - Parameters + * - Examples + * + * Useful for multi-agent architectures where planner agents need + * comprehensive skill information to create execution plans. + */ +@Flow({ + name, + plan, + inputSchema, + outputSchema, + access: 'public', // Will use endpoint-specific auth if configured + middleware: { + method: 'GET', + }, +}) +export default class LlmFullTxtFlow extends FlowBase { + logger = this.scopeLogger.child('LlmFullTxtFlow'); + + /** + * Check if this flow should handle the request. + * Matches GET requests to /llm_full.txt or configured path. + */ + static canActivate(request: ServerRequest, scope: ScopeEntry): boolean { + if (request.method !== 'GET') return false; + + const skillsConfig = scope.metadata.skillsConfig; + if (!skillsConfig?.enabled) return false; + + const options = normalizeSkillsConfigOptions(skillsConfig); + if (!options.normalizedLlmFullTxt.enabled) return false; + + const entryPrefix = normalizeEntryPrefix(scope.entryPath); + const scopeBase = normalizeScopeBase(scope.routeBase); + const basePath = `${entryPrefix}${scopeBase}`; + const endpointPath = options.normalizedLlmFullTxt.path ?? '/llm_full.txt'; + + // Support both /llm_full.txt and {basePath}/llm_full.txt + const paths = new Set([endpointPath, `${basePath}${endpointPath}`]); + + return paths.has(request.path); + } + + @Stage('checkEnabled') + async checkEnabled() { + const skillsConfig = this.scope.metadata.skillsConfig; + if (!skillsConfig?.enabled) { + this.respond(httpRespond.notFound('Skills HTTP endpoints not enabled')); + return; + } + + const options = normalizeSkillsConfigOptions(skillsConfig); + if (!options.normalizedLlmFullTxt.enabled) { + this.respond(httpRespond.notFound('llm_full.txt endpoint not enabled')); + return; + } + + // Validate auth if configured + const authValidator = createSkillHttpAuthValidator(skillsConfig, this.logger); + if (authValidator) { + const { request } = this.rawInput; + const authResult = await authValidator.validate({ + headers: request.headers as Record, + }); + + if (!authResult.authorized) { + this.respond({ + kind: 'text', + status: authResult.statusCode ?? 401, + body: authResult.error ?? 'Unauthorized', + contentType: 'text/plain; charset=utf-8', + }); + return; + } + } + + this.state.set({ prefix: options.prefix ?? '' }); + } + + @Stage('generateContent') + async generateContent() { + const skillRegistry = this.scope.skills; + const toolRegistry = this.scope.tools; + + if (!skillRegistry || !skillRegistry.hasAny()) { + this.respond({ + kind: 'text', + status: 200, + body: '# No skills available\n\nNo skills have been registered on this server.', + contentType: 'text/plain; charset=utf-8', + }); + return; + } + + // Check cache first + const cache = await getSkillHttpCache(this.scope); + if (cache) { + const cached = await cache.getLlmFullTxt(); + if (cached) { + this.respond({ + kind: 'text', + status: 200, + body: cached, + contentType: 'text/plain; charset=utf-8', + }); + return; + } + } + + // Generate full content with tool schemas + const content = await formatSkillsForLlmFull(skillRegistry, toolRegistry, 'http'); + + if (!content || content.trim() === '') { + this.respond({ + kind: 'text', + status: 200, + body: '# No skills available\n\nNo skills are visible via HTTP on this server.', + contentType: 'text/plain; charset=utf-8', + }); + return; + } + + // Store in cache + if (cache) { + await cache.setLlmFullTxt(content); + } + + this.respond({ + kind: 'text', + status: 200, + body: content, + contentType: 'text/plain; charset=utf-8', + }); + } +} diff --git a/libs/sdk/src/skill/flows/http/llm-txt.flow.ts b/libs/sdk/src/skill/flows/http/llm-txt.flow.ts new file mode 100644 index 00000000..b1137816 --- /dev/null +++ b/libs/sdk/src/skill/flows/http/llm-txt.flow.ts @@ -0,0 +1,204 @@ +// file: libs/sdk/src/skill/flows/http/llm-txt.flow.ts + +/** + * HTTP flow for GET /llm.txt endpoint. + * Returns compact skill summaries in plain text format. + */ + +import { + Flow, + FlowBase, + FlowPlan, + FlowRunOptions, + httpInputSchema, + HttpTextSchema, + httpRespond, + ScopeEntry, + ServerRequest, + FlowHooksOf, + normalizeEntryPrefix, + normalizeScopeBase, +} from '../../../common'; +import { z } from 'zod'; +import { formatSkillsForLlmCompact } from '../../skill-http.utils'; +import { normalizeSkillsConfigOptions } from '../../../common/types/options/skills-http'; +import { createSkillHttpAuthValidator } from '../../auth'; +import { getSkillHttpCache } from '../../cache'; + +const inputSchema = httpInputSchema; + +const stateSchema = z.object({ + prefix: z.string(), +}); + +const outputSchema = HttpTextSchema; + +const plan = { + pre: ['checkEnabled'], + execute: ['generateContent'], +} as const satisfies FlowPlan; + +declare global { + interface ExtendFlows { + 'skills-http:llm-txt': FlowRunOptions< + LlmTxtFlow, + typeof plan, + typeof inputSchema, + typeof outputSchema, + typeof stateSchema + >; + } +} + +const name = 'skills-http:llm-txt' as const; +const { Stage } = FlowHooksOf<'skills-http:llm-txt'>(name); + +/** + * Flow for serving skill summaries at /llm.txt. + * + * This endpoint provides a compact, LLM-friendly summary of all available skills. + * Each skill is listed with its name, description, tools, and tags. + * + * @example Response format + * ``` + * # review-pr + * Review a GitHub pull request + * Tools: github_get_pr, github_add_comment + * Tags: github, code-review + * + * --- + * + * # deploy-app + * Deploy application to production + * ``` + */ +@Flow({ + name, + plan, + inputSchema, + outputSchema, + access: 'public', // Will use endpoint-specific auth if configured + middleware: { + method: 'GET', + }, +}) +export default class LlmTxtFlow extends FlowBase { + logger = this.scopeLogger.child('LlmTxtFlow'); + + /** + * Check if this flow should handle the request. + * Matches GET requests to /llm.txt or configured path. + */ + static canActivate(request: ServerRequest, scope: ScopeEntry): boolean { + if (request.method !== 'GET') return false; + + const skillsConfig = scope.metadata.skillsConfig; + if (!skillsConfig?.enabled) return false; + + const options = normalizeSkillsConfigOptions(skillsConfig); + if (!options.normalizedLlmTxt.enabled) return false; + + const entryPrefix = normalizeEntryPrefix(scope.entryPath); + const scopeBase = normalizeScopeBase(scope.routeBase); + const basePath = `${entryPrefix}${scopeBase}`; + const endpointPath = options.normalizedLlmTxt.path ?? '/llm.txt'; + + // Support both /llm.txt and {basePath}/llm.txt + const paths = new Set([endpointPath, `${basePath}${endpointPath}`]); + + return paths.has(request.path); + } + + @Stage('checkEnabled') + async checkEnabled() { + const skillsConfig = this.scope.metadata.skillsConfig; + if (!skillsConfig?.enabled) { + this.respond(httpRespond.notFound('Skills HTTP endpoints not enabled')); + return; + } + + const options = normalizeSkillsConfigOptions(skillsConfig); + if (!options.normalizedLlmTxt.enabled) { + this.respond(httpRespond.notFound('llm.txt endpoint not enabled')); + return; + } + + // Validate auth if configured + const authValidator = createSkillHttpAuthValidator(skillsConfig, this.logger); + if (authValidator) { + const { request } = this.rawInput; + const authResult = await authValidator.validate({ + headers: request.headers as Record, + }); + + if (!authResult.authorized) { + this.respond({ + kind: 'text', + status: authResult.statusCode ?? 401, + body: authResult.error ?? 'Unauthorized', + contentType: 'text/plain; charset=utf-8', + }); + return; + } + } + + this.state.set({ prefix: options.prefix ?? '' }); + } + + @Stage('generateContent') + async generateContent() { + const skillRegistry = this.scope.skills; + + if (!skillRegistry || !skillRegistry.hasAny()) { + this.respond({ + kind: 'text', + status: 200, + body: '# No skills available\n\nNo skills have been registered on this server.', + contentType: 'text/plain; charset=utf-8', + }); + return; + } + + // Check cache first + const cache = await getSkillHttpCache(this.scope); + if (cache) { + const cached = await cache.getLlmTxt(); + if (cached) { + this.respond({ + kind: 'text', + status: 200, + body: cached, + contentType: 'text/plain; charset=utf-8', + }); + return; + } + } + + // Get skills visible via HTTP + const skills = skillRegistry.getSkills({ includeHidden: false, visibility: 'http' }); + + if (skills.length === 0) { + this.respond({ + kind: 'text', + status: 200, + body: '# No skills available\n\nNo skills are visible via HTTP on this server.', + contentType: 'text/plain; charset=utf-8', + }); + return; + } + + const content = formatSkillsForLlmCompact(skills); + + // Store in cache + if (cache) { + await cache.setLlmTxt(content); + } + + this.respond({ + kind: 'text', + status: 200, + body: content, + contentType: 'text/plain; charset=utf-8', + }); + } +} diff --git a/libs/sdk/src/skill/flows/http/skills-api.flow.ts b/libs/sdk/src/skill/flows/http/skills-api.flow.ts new file mode 100644 index 00000000..79f663b4 --- /dev/null +++ b/libs/sdk/src/skill/flows/http/skills-api.flow.ts @@ -0,0 +1,355 @@ +// file: libs/sdk/src/skill/flows/http/skills-api.flow.ts + +/** + * HTTP flow for GET /skills/* API endpoints. + * Provides JSON API for listing, searching, and loading skills. + */ + +import { + Flow, + FlowBase, + FlowPlan, + FlowRunOptions, + httpInputSchema, + HttpJsonSchema, + httpRespond, + ScopeEntry, + ServerRequest, + FlowHooksOf, + normalizeEntryPrefix, + normalizeScopeBase, +} from '../../../common'; +import { z } from 'zod'; +import { skillToApiResponse, formatSkillForLLMWithSchemas } from '../../skill-http.utils'; +import { normalizeSkillsConfigOptions } from '../../../common/types/options/skills-http'; +import { createSkillHttpAuthValidator } from '../../auth'; + +const inputSchema = httpInputSchema; + +const stateSchema = z.object({ + action: z.enum(['list', 'search', 'get']), + skillId: z.string().optional(), + query: z.string().optional(), + tags: z.array(z.string()).optional(), + tools: z.array(z.string()).optional(), + limit: z.number().optional(), + offset: z.number().optional(), +}); + +const outputSchema = HttpJsonSchema; + +const plan = { + pre: ['checkEnabled', 'parseRequest'], + execute: ['handleRequest'], +} as const satisfies FlowPlan; + +declare global { + interface ExtendFlows { + 'skills-http:api': FlowRunOptions< + SkillsApiFlow, + typeof plan, + typeof inputSchema, + typeof outputSchema, + typeof stateSchema + >; + } +} + +const name = 'skills-http:api' as const; +const { Stage } = FlowHooksOf<'skills-http:api'>(name); + +/** + * Flow for serving skills via JSON API. + * + * Endpoints: + * - GET /skills - List all skills + * - GET /skills?query=X - Search skills + * - GET /skills?tags=a,b - Filter by tags + * - GET /skills/{id} - Get specific skill by ID/name + */ +@Flow({ + name, + plan, + inputSchema, + outputSchema, + access: 'public', // Will use endpoint-specific auth if configured + middleware: { + method: 'GET', + }, +}) +export default class SkillsApiFlow extends FlowBase { + logger = this.scopeLogger.child('SkillsApiFlow'); + + /** + * Check if this flow should handle the request. + * Matches GET requests to /skills or /skills/{id}. + */ + static canActivate(request: ServerRequest, scope: ScopeEntry): boolean { + if (request.method !== 'GET') return false; + + const skillsConfig = scope.metadata.skillsConfig; + if (!skillsConfig?.enabled) return false; + + const options = normalizeSkillsConfigOptions(skillsConfig); + if (!options.normalizedApi.enabled) return false; + + const entryPrefix = normalizeEntryPrefix(scope.entryPath); + const scopeBase = normalizeScopeBase(scope.routeBase); + const basePath = `${entryPrefix}${scopeBase}`; + const apiPath = options.normalizedApi.path ?? '/skills'; + + const fullPath = `${basePath}${apiPath}`; + const path = request.path; + + // Match /skills or /skills/{id} + return path === apiPath || path.startsWith(`${apiPath}/`) || path === fullPath || path.startsWith(`${fullPath}/`); + } + + @Stage('checkEnabled') + async checkEnabled() { + const skillsConfig = this.scope.metadata.skillsConfig; + if (!skillsConfig?.enabled) { + this.respond( + httpRespond.json({ error: 'Not Found', message: 'Skills HTTP endpoints not enabled' }, { status: 404 }), + ); + return; + } + + const options = normalizeSkillsConfigOptions(skillsConfig); + if (!options.normalizedApi.enabled) { + this.respond( + httpRespond.json({ error: 'Not Found', message: 'Skills API endpoint not enabled' }, { status: 404 }), + ); + return; + } + + // Validate auth if configured + const authValidator = createSkillHttpAuthValidator(skillsConfig, this.logger); + if (authValidator) { + const { request } = this.rawInput; + const authResult = await authValidator.validate({ + headers: request.headers as Record, + }); + + if (!authResult.authorized) { + this.respond( + httpRespond.json( + { error: 'Unauthorized', message: authResult.error ?? 'Authentication required' }, + { status: authResult.statusCode ?? 401 }, + ), + ); + return; + } + } + } + + @Stage('parseRequest') + async parseRequest() { + const { request } = this.rawInput; + const skillsConfig = this.scope.metadata.skillsConfig; + const options = normalizeSkillsConfigOptions(skillsConfig); + + const entryPrefix = normalizeEntryPrefix(this.scope.entryPath); + const scopeBase = normalizeScopeBase(this.scope.routeBase); + const basePath = `${entryPrefix}${scopeBase}`; + const apiPath = options.normalizedApi.path ?? '/skills'; + const fullPath = `${basePath}${apiPath}`; + + // Extract skill ID from path if present + let skillId: string | undefined; + const path = request.path; + + if (path.startsWith(`${fullPath}/`)) { + skillId = path.slice(fullPath.length + 1); + } else if (path.startsWith(`${apiPath}/`)) { + skillId = path.slice(apiPath.length + 1); + } + + // Parse query parameters + const query = request.query?.['query'] as string | undefined; + const tagsParam = request.query?.['tags'] as string | string[] | undefined; + const toolsParam = request.query?.['tools'] as string | string[] | undefined; + const limitParam = request.query?.['limit'] as string | undefined; + const offsetParam = request.query?.['offset'] as string | undefined; + + // Normalize tags and tools arrays + const tags = tagsParam ? (Array.isArray(tagsParam) ? tagsParam : tagsParam.split(',')) : undefined; + const tools = toolsParam ? (Array.isArray(toolsParam) ? toolsParam : toolsParam.split(',')) : undefined; + const limit = limitParam ? parseInt(limitParam, 10) : undefined; + const offset = offsetParam ? parseInt(offsetParam, 10) : undefined; + + // Determine action + let action: 'list' | 'search' | 'get'; + if (skillId) { + action = 'get'; + } else if (query) { + action = 'search'; + } else { + action = 'list'; + } + + this.state.set({ action, skillId, query, tags, tools, limit, offset }); + } + + @Stage('handleRequest') + async handleRequest() { + const state = this.state.snapshot(); + const { action, skillId, query, tags, tools, limit, offset } = state; + const skillRegistry = this.scope.skills; + const toolRegistry = this.scope.tools; + + if (!skillRegistry) { + this.respond( + httpRespond.json( + { error: 'Skills not configured', message: 'No skill registry available on this server' }, + { status: 500 }, + ), + ); + return; + } + + switch (action) { + case 'get': + await this.handleGetSkill(skillId!, skillRegistry, toolRegistry); + break; + case 'search': + await this.handleSearchSkills(query!, { tags, tools, limit }, skillRegistry); + break; + case 'list': + await this.handleListSkills({ tags, tools, limit, offset }, skillRegistry); + break; + } + } + + private async handleGetSkill(skillId: string, skillRegistry: any, toolRegistry: any) { + const loadResult = await skillRegistry.loadSkill(skillId); + + if (!loadResult) { + this.respond( + httpRespond.json({ error: 'Skill not found', message: `Skill "${skillId}" not found` }, { status: 404 }), + ); + return; + } + + const { skill, availableTools, missingTools, isComplete, warning } = loadResult; + + // Check visibility + const skillEntry = skillRegistry + .getSkills(true) + .find((s: any) => s.name === skill.name || s.metadata.id === skill.id); + if (skillEntry) { + const visibility = skillEntry.metadata.visibility ?? 'both'; + if (visibility === 'mcp') { + this.respond( + httpRespond.json( + { error: 'Skill not found', message: `Skill "${skillId}" not available via HTTP` }, + { status: 404 }, + ), + ); + return; + } + } + + // Generate formatted content with tool schemas + const formattedContent = formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry); + + this.respond( + httpRespond.json({ + skill: { + id: skill.id, + name: skill.name, + description: skill.description, + instructions: skill.instructions, + tools: skill.tools.map((t: any) => ({ + name: t.name, + purpose: t.purpose, + available: availableTools.includes(t.name), + })), + parameters: skill.parameters, + examples: skill.examples, + }, + availableTools, + missingTools, + isComplete, + warning, + formattedContent, + }), + ); + } + + private async handleSearchSkills( + query: string, + options: { tags?: string[]; tools?: string[]; limit?: number }, + skillRegistry: any, + ) { + const results = await skillRegistry.search(query, { + topK: options.limit ?? 10, + tags: options.tags, + tools: options.tools, + }); + + // Filter by HTTP visibility + const filteredResults = results.filter((r: any) => { + const visibility = r.metadata.visibility ?? 'both'; + return visibility !== 'mcp'; + }); + + this.respond( + httpRespond.json({ + skills: filteredResults.map((r: any) => ({ + id: r.metadata.id ?? r.metadata.name, + name: r.metadata.name, + description: r.metadata.description, + score: r.score, + tags: r.metadata.tags ?? [], + tools: (r.metadata.tools ?? []).map((t: any) => (typeof t === 'string' ? t : t.name)), + priority: r.metadata.priority ?? 0, + visibility: r.metadata.visibility ?? 'both', + })), + total: filteredResults.length, + }), + ); + } + + private async handleListSkills( + options: { tags?: string[]; tools?: string[]; limit?: number; offset?: number }, + skillRegistry: any, + ) { + // Get skills visible via HTTP + const allSkills = skillRegistry.getSkills({ includeHidden: false, visibility: 'http' }); + + // Apply tag filter if specified + let filteredSkills = allSkills; + if (options.tags && options.tags.length > 0) { + filteredSkills = filteredSkills.filter((s: any) => { + const skillTags = s.metadata.tags ?? []; + return options.tags!.some((t) => skillTags.includes(t)); + }); + } + + // Apply tool filter if specified + if (options.tools && options.tools.length > 0) { + filteredSkills = filteredSkills.filter((s: any) => { + const skillTools = (s.metadata.tools ?? []).map((t: any) => (typeof t === 'string' ? t : t.name)); + return options.tools!.some((t) => skillTools.includes(t)); + }); + } + + // Apply pagination + const total = filteredSkills.length; + const offset = options.offset ?? 0; + const limit = options.limit ?? 50; + const paginatedSkills = filteredSkills.slice(offset, offset + limit); + const hasMore = offset + limit < total; + + this.respond( + httpRespond.json({ + skills: paginatedSkills.map((s: any) => skillToApiResponse(s)), + total, + hasMore, + offset, + limit, + }), + ); + } +} diff --git a/libs/sdk/src/skill/flows/index.ts b/libs/sdk/src/skill/flows/index.ts index ea8b7f75..4755393b 100644 --- a/libs/sdk/src/skill/flows/index.ts +++ b/libs/sdk/src/skill/flows/index.ts @@ -12,3 +12,6 @@ export { default as SearchSkillsFlow } from './search-skills.flow'; export { default as LoadSkillFlow } from './load-skill.flow'; + +// HTTP Flows (conditionally registered when skillsConfig.enabled is true) +export { LlmTxtFlow, LlmFullTxtFlow, SkillsApiFlow } from './http'; diff --git a/libs/sdk/src/skill/flows/load-skill.flow.ts b/libs/sdk/src/skill/flows/load-skill.flow.ts index 7bb1db6a..fb338d8f 100644 --- a/libs/sdk/src/skill/flows/load-skill.flow.ts +++ b/libs/sdk/src/skill/flows/load-skill.flow.ts @@ -3,17 +3,19 @@ import { Flow, FlowBase, FlowHooksOf, FlowPlan, FlowRunOptions } from '../../common'; import { z } from 'zod'; import { InvalidInputError, InternalMcpError } from '../../errors'; -import { formatSkillForLLM } from '../skill.utils'; +import { formatSkillForLLM, generateNextSteps } from '../skill.utils'; +import { formatSkillForLLMWithSchemas } from '../skill-http.utils'; import type { SkillLoadResult } from '../skill-storage.interface'; import type { SkillSessionManager } from '../session/skill-session.manager'; import type { SkillPolicyMode, SkillActivationResult } from '../session/skill-session.types'; +import type { Scope } from '../../scope'; -// Input schema matching MCP request format +// Input schema matching MCP request format - now supports multiple skill IDs const inputSchema = z.object({ request: z.object({ method: z.literal('skills/load'), params: z.object({ - skillId: z.string().min(1).describe('ID or name of the skill to load'), + skillIds: z.array(z.string().min(1)).min(1).max(5).describe('Array of skill IDs or names to load (1-5 skills)'), format: z .enum(['full', 'instructions-only']) .default('full') @@ -31,36 +33,36 @@ const inputSchema = z.object({ ctx: z.unknown(), }); -// Output schema -const outputSchema = z.object({ - skill: z.object({ - id: z.string(), - name: z.string(), - description: z.string(), - instructions: z.string(), - tools: z.array( +// Single skill result schema +const skillResultSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + instructions: z.string(), + tools: z.array( + z.object({ + name: z.string(), + purpose: z.string().optional(), + available: z.boolean(), + inputSchema: z.unknown().optional().describe('JSON Schema for tool input'), + outputSchema: z.unknown().optional().describe('JSON Schema for tool output'), + }), + ), + parameters: z + .array( z.object({ name: z.string(), - purpose: z.string().optional(), - available: z.boolean(), + description: z.string().optional(), + required: z.boolean().optional(), + type: z.string().optional(), }), - ), - parameters: z - .array( - z.object({ - name: z.string(), - description: z.string().optional(), - required: z.boolean().optional(), - type: z.string().optional(), - }), - ) - .optional(), - }), + ) + .optional(), availableTools: z.array(z.string()), missingTools: z.array(z.string()), isComplete: z.boolean(), warning: z.string().optional(), - formattedContent: z.string().describe('Formatted skill content ready for LLM consumption'), + formattedContent: z.string().describe('Formatted skill content ready for LLM consumption (includes tool schemas)'), // Session activation info (only present when activateSession is true) session: z .object({ @@ -72,22 +74,40 @@ const outputSchema = z.object({ .optional(), }); +// Output schema with multiple skills and summary +const outputSchema = z.object({ + skills: z.array(skillResultSchema), + summary: z.object({ + totalSkills: z.number(), + totalTools: z.number(), + allToolsAvailable: z.boolean(), + combinedWarnings: z.array(z.string()).optional(), + }), + nextSteps: z.string().describe('Guidance on what to do next with the loaded skills'), +}); + type Input = z.infer; type Output = z.infer; +// Load result with activation info for state +interface LoadResultWithActivation { + loadResult: SkillLoadResult; + activationResult?: SkillActivationResult; +} + const stateSchema = z.object({ - skillId: z.string(), + skillIds: z.array(z.string()), format: z.enum(['full', 'instructions-only']), activateSession: z.boolean(), policyMode: z.enum(['strict', 'approval', 'permissive']).optional(), - loadResult: z.unknown().optional() as z.ZodType, - activationResult: z.unknown().optional() as z.ZodType, + loadResults: z.unknown().optional() as z.ZodType, + warnings: z.array(z.string()).optional(), output: outputSchema.optional(), }); const plan = { pre: ['parseInput'], - execute: ['loadSkill', 'activateSession'], + execute: ['loadSkills', 'activateSessions'], finalize: ['finalize'], } as const satisfies FlowPlan; @@ -107,17 +127,17 @@ const name = 'skills:load' as const; const { Stage } = FlowHooksOf<'skills:load'>(name); /** - * Flow for loading a skill's full content. + * Flow for loading one or more skills' full content. * - * This flow retrieves a skill's instructions, tool requirements, and parameters. - * Use this after searching for skills to get the detailed workflow guide. + * This flow retrieves skill instructions, tool requirements, and parameters. + * Use this after searching for skills to get the detailed workflow guides. * * @example MCP Request * ```json * { * "method": "skills/load", * "params": { - * "skillId": "review-pr", + * "skillIds": ["review-pr", "suggest-fixes"], * "format": "full" * } * } @@ -145,15 +165,15 @@ export default class LoadSkillFlow extends FlowBase { throw new InvalidInputError('Invalid Input', e instanceof z.ZodError ? e.issues : undefined); } - const { skillId, format, activateSession, policyMode } = params; - this.state.set({ skillId, format, activateSession, policyMode }); + const { skillIds, format, activateSession, policyMode } = params; + this.state.set({ skillIds, format, activateSession, policyMode, warnings: [] }); this.logger.verbose('parseInput:done'); } - @Stage('loadSkill') - async loadSkill() { - this.logger.verbose('loadSkill:start'); - const { skillId } = this.state.required; + @Stage('loadSkills') + async loadSkills() { + this.logger.verbose('loadSkills:start'); + const { skillIds, warnings = [] } = this.state.required; const skillRegistry = this.scope.skills; @@ -161,90 +181,154 @@ export default class LoadSkillFlow extends FlowBase { throw new InternalMcpError('Skill registry not configured'); } - // Load the skill - const result = await skillRegistry.loadSkill(skillId); + const loadResults: LoadResultWithActivation[] = []; - if (!result) { - throw new InvalidInputError(`Skill "${skillId}" not found`); + for (const skillId of skillIds) { + const result = await skillRegistry.loadSkill(skillId); + + if (!result) { + warnings.push(`Skill "${skillId}" not found`); + continue; + } + + loadResults.push({ loadResult: result }); } - // Store load result for session activation - this.state.set({ loadResult: result }); - this.logger.verbose('loadSkill:done'); + this.state.set({ loadResults, warnings }); + this.logger.verbose('loadSkills:done', { loaded: loadResults.length, notFound: warnings.length }); } /** - * Activate a skill session for tool authorization enforcement. + * Activate skill sessions for tool authorization enforcement. * This stage only runs if activateSession is true in the input. */ - @Stage('activateSession') - async activateSession() { - this.logger.verbose('activateSession:start'); - const { activateSession, policyMode, loadResult } = this.state.required; + @Stage('activateSessions') + async activateSessions() { + this.logger.verbose('activateSessions:start'); + const { activateSession, policyMode, loadResults } = this.state.required; - if (!activateSession || !loadResult) { - this.logger.verbose('activateSession:skip (not requested or no skill loaded)'); + if (!activateSession || !loadResults || loadResults.length === 0) { + this.logger.verbose('activateSessions:skip (not requested or no skills loaded)'); return; } // Try to get skill session manager from scope - // The manager is optional - it may not be configured const scope = this.scope as { skillSession?: SkillSessionManager }; const sessionManager = scope.skillSession; if (!sessionManager) { - this.logger.verbose('activateSession:skip (no session manager available)'); + this.logger.verbose('activateSessions:skip (no session manager available)'); return; } // Check if we're in a session context const existingSession = sessionManager.getActiveSession(); if (!existingSession) { - this.logger.warn('activateSession: not in a session context, cannot activate skill session'); + this.logger.warn('activateSessions: not in a session context, cannot activate skill sessions'); return; } - // Activate the skill - const { skill } = loadResult; - const activationResult = sessionManager.activateSkill(skill.id, skill, loadResult); + // Activate each skill + for (const item of loadResults) { + const { skill } = item.loadResult; + const activationResult = sessionManager.activateSkill(skill.id, skill, item.loadResult); + + // Override policy mode if specified + if (policyMode) { + sessionManager.setPolicyMode(policyMode as SkillPolicyMode); + } - // Override policy mode if specified - if (policyMode) { - sessionManager.setPolicyMode(policyMode as SkillPolicyMode); + item.activationResult = activationResult; + this.logger.info(`activateSessions: activated skill "${skill.id}"`, { + policyMode: activationResult.session.policyMode, + allowedTools: activationResult.availableTools, + }); } - this.state.set({ activationResult }); - this.logger.info(`activateSession: activated skill "${skill.id}"`, { - policyMode: activationResult.session.policyMode, - allowedTools: activationResult.availableTools, - }); - this.logger.verbose('activateSession:done'); + this.state.set({ loadResults }); + this.logger.verbose('activateSessions:done'); } @Stage('finalize') async finalize() { this.logger.verbose('finalize:start'); - const { loadResult, activationResult, format, activateSession } = this.state.required; - - if (!loadResult) { - throw new InvalidInputError('Skill not loaded'); + const { loadResults, warnings = [], format, activateSession } = this.state.required; + + if (!loadResults || loadResults.length === 0) { + // Return empty result with guidance + const output: Output = { + skills: [], + summary: { + totalSkills: 0, + totalTools: 0, + allToolsAvailable: true, + combinedWarnings: warnings.length > 0 ? warnings : undefined, + }, + nextSteps: + 'No skills were loaded. ' + + (warnings.length > 0 ? warnings.join('; ') : 'Try searchSkills to find available skills.'), + }; + this.respond(output); + return; } - const { skill, availableTools, missingTools, isComplete, warning } = loadResult; + const toolRegistry = (this.scope as Scope).tools; + const skillResults: z.infer[] = []; + let totalTools = 0; + let allToolsAvailable = true; - // Build tools array with availability info - const tools = skill.tools.map((t) => ({ - name: t.name, - purpose: t.purpose, - available: availableTools.includes(t.name), - })); + for (const { loadResult, activationResult } of loadResults) { + const { skill, availableTools, missingTools, isComplete, warning } = loadResult; - // Format content for LLM - const formattedContent = - format === 'instructions-only' ? skill.instructions : formatSkillForLLM(skill, availableTools, missingTools); + if (missingTools.length > 0) { + allToolsAvailable = false; + } - const output: Output = { - skill: { + // Build tools array with availability info and schemas + const tools = skill.tools.map((t) => { + const isAvailable = availableTools.includes(t.name); + const result: { + name: string; + purpose?: string; + available: boolean; + inputSchema?: unknown; + outputSchema?: unknown; + } = { + name: t.name, + purpose: t.purpose, + available: isAvailable, + }; + + // Include schemas for available tools + if (isAvailable && toolRegistry) { + const toolEntry = toolRegistry.getTools(true).find((te) => te.name === t.name); + if (toolEntry) { + if (toolEntry.rawInputSchema) { + result.inputSchema = toolEntry.rawInputSchema; + } + const rawOutput = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema; + if (rawOutput) { + result.outputSchema = rawOutput; + } + } + } + + return result; + }); + + totalTools += tools.length; + + // Format content for LLM + let formattedContent: string; + if (format === 'instructions-only') { + formattedContent = skill.instructions; + } else if (toolRegistry) { + formattedContent = formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry); + } else { + formattedContent = formatSkillForLLM(skill, availableTools, missingTools); + } + + const skillResult: z.infer = { id: skill.id, name: skill.name, description: skill.description, @@ -256,30 +340,61 @@ export default class LoadSkillFlow extends FlowBase { required: p.required, type: p.type, })), - }, - availableTools, - missingTools, - isComplete, - warning, - formattedContent, - }; + availableTools, + missingTools, + isComplete, + warning, + formattedContent, + }; + + // Add session info if activation was requested + if (activateSession) { + if (activationResult) { + skillResult.session = { + activated: true, + sessionId: activationResult.session.sessionId, + policyMode: activationResult.session.policyMode, + allowedTools: activationResult.availableTools, + }; + } else { + skillResult.session = { + activated: false, + }; + } + } - // Add session info if activation was requested - if (activateSession) { - if (activationResult) { - output.session = { - activated: true, - sessionId: activationResult.session.sessionId, - policyMode: activationResult.session.policyMode, - allowedTools: activationResult.availableTools, - }; - } else { - output.session = { - activated: false, - }; + skillResults.push(skillResult); + } + + // Collect all warnings + const allWarnings = [...warnings]; + for (const result of skillResults) { + if (result.warning) { + allWarnings.push(result.warning); } } + // Generate next steps guidance + const nextSteps = generateNextSteps( + skillResults.map((r) => ({ + name: r.name, + isComplete: r.isComplete, + tools: r.tools, + })), + allToolsAvailable, + ); + + const output: Output = { + skills: skillResults, + summary: { + totalSkills: skillResults.length, + totalTools, + allToolsAvailable, + combinedWarnings: allWarnings.length > 0 ? allWarnings : undefined, + }, + nextSteps, + }; + this.respond(output); this.logger.verbose('finalize:done'); } diff --git a/libs/sdk/src/skill/flows/search-skills.flow.ts b/libs/sdk/src/skill/flows/search-skills.flow.ts index e1245902..4da7f1b7 100644 --- a/libs/sdk/src/skill/flows/search-skills.flow.ts +++ b/libs/sdk/src/skill/flows/search-skills.flow.ts @@ -157,8 +157,14 @@ export default class SearchSkillsFlow extends FlowBase { this.logger.verbose('finalize:start'); const { results, options } = this.state.required; + // Filter by MCP visibility (only 'mcp' or 'both' should be visible via MCP tools) + const mcpVisibleResults = (results as SkillSearchResult[]).filter((result) => { + const visibility = result.metadata.visibility ?? 'both'; + return visibility === 'mcp' || visibility === 'both'; + }); + // Transform results to output format - const skills = (results as SkillSearchResult[]).map((result) => ({ + const skills = mcpVisibleResults.map((result) => ({ id: result.metadata.id ?? result.metadata.name, name: result.metadata.name, description: result.metadata.description, diff --git a/libs/sdk/src/skill/index.ts b/libs/sdk/src/skill/index.ts index 171fb5f8..42a13aca 100644 --- a/libs/sdk/src/skill/index.ts +++ b/libs/sdk/src/skill/index.ts @@ -36,7 +36,7 @@ // Registry export { default as SkillRegistry } from './skill.registry'; -export type { SkillRegistryInterface, IndexedSkill, SkillRegistryOptions } from './skill.registry'; +export type { SkillRegistryInterface, IndexedSkill, SkillRegistryOptions, GetSkillsOptions } from './skill.registry'; // Instance export { SkillInstance, createSkillInstance } from './skill.instance'; @@ -113,11 +113,21 @@ export { formatSkillForLLM, } from './skill.utils'; +// HTTP Utilities +export { + formatSkillsForLlmCompact, + formatSkillsForLlmFull, + formatSkillForLLMWithSchemas, + skillToApiResponse, + filterSkillsByVisibility, +} from './skill-http.utils'; +export type { CompactSkillSummary } from './skill-http.utils'; + // Flows export { SearchSkillsFlow, LoadSkillFlow } from './flows'; // Tools (deprecated - use flows instead) -export { SearchSkillsTool, LoadSkillTool, getSkillTools } from './tools'; +export { SearchSkillsTool, LoadSkillsTool, LoadSkillTool, getSkillTools } from './tools'; // Session Management export { SkillSessionManager } from './session/skill-session.manager'; @@ -146,3 +156,28 @@ export { createSkillToolGuardHook, type SkillToolGuardHookOptions, type SkillToo export { ToolNotAllowedError, ToolApprovalRequiredError } from './errors/tool-not-allowed.error'; export { SkillValidationError } from './errors/skill-validation.error'; export type { SkillValidationResult, SkillValidationReport } from './errors/skill-validation.error'; + +// Scope Helper +export { registerSkillCapabilities } from './skill-scope.helper'; +export type { SkillScopeRegistrationOptions } from './skill-scope.helper'; + +// Mode Utilities +export { detectSkillsOnlyMode, isSkillsOnlySession } from './skill-mode.utils'; +export type { SkillsOnlySessionPayload } from './skill-mode.utils'; + +// HTTP Authentication +export { SkillHttpAuthValidator, createSkillHttpAuthValidator } from './auth'; +export type { SkillHttpAuthContext, SkillHttpAuthResult, SkillHttpAuthValidatorOptions } from './auth'; + +// HTTP Caching +export { + SkillHttpCache, + MemorySkillHttpCache, + RedisSkillHttpCache, + createSkillHttpCache, + getSkillHttpCache, + invalidateScopeCache, + invalidateSkillInCache, + disposeAllCaches, +} from './cache'; +export type { SkillHttpCacheOptions, SkillHttpCacheResult } from './cache'; diff --git a/libs/sdk/src/skill/providers/memory-skill.provider.ts b/libs/sdk/src/skill/providers/memory-skill.provider.ts index cae32923..1224dedd 100644 --- a/libs/sdk/src/skill/providers/memory-skill.provider.ts +++ b/libs/sdk/src/skill/providers/memory-skill.provider.ts @@ -2,7 +2,7 @@ import { TFIDFVectoria, DocumentMetadata } from 'vectoriadb'; import { SkillContent } from '../../common/interfaces'; -import { SkillMetadata } from '../../common/metadata'; +import { SkillMetadata, SkillVisibility } from '../../common/metadata'; import { SkillToolValidator, ToolValidationResult } from '../skill-validator'; import { SkillStorageProviderType, @@ -108,6 +108,7 @@ interface StoredSkillContent extends SkillContent { tags?: string[]; priority?: number; hideFromDiscovery?: boolean; + visibility?: SkillVisibility; } /** @@ -508,6 +509,7 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { const tags = this.getSkillTags(skill); const priority = this.getPriority(skill); const hideFromDiscovery = this.isHidden(skill); + const visibility = this.getVisibility(skill); return { id: skill.id, @@ -531,6 +533,8 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { ...(tags.length > 0 && { tags }), ...(priority !== undefined && { priority }), ...(hideFromDiscovery && { hideFromDiscovery }), + // Always include visibility for filtering + visibility: visibility ?? 'both', }; } @@ -557,4 +561,11 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { private getPriority(skill: SkillContent): number | undefined { return (skill as StoredSkillContent).priority; } + + /** + * Get visibility of a skill. + */ + private getVisibility(skill: SkillContent): SkillVisibility | undefined { + return (skill as StoredSkillContent).visibility; + } } diff --git a/libs/sdk/src/skill/skill-http.utils.ts b/libs/sdk/src/skill/skill-http.utils.ts new file mode 100644 index 00000000..5327b2e2 --- /dev/null +++ b/libs/sdk/src/skill/skill-http.utils.ts @@ -0,0 +1,306 @@ +// file: libs/sdk/src/skill/skill-http.utils.ts + +/** + * Utilities for formatting skills for HTTP endpoints. + * + * These utilities provide formatted output for: + * - /llm.txt - Compact skill summaries + * - /llm_full.txt - Full skills with instructions and tool schemas + * - /skills API - JSON responses + */ + +import type { SkillContent, SkillEntry, ToolRegistryInterface, ToolEntry } from '../common'; +import type { SkillVisibility } from '../common/metadata/skill.metadata'; +import type { SkillRegistryInterface as SkillRegistryInterfaceType } from './skill.registry'; + +/** + * Compact skill summary for /llm.txt endpoint. + */ +export interface CompactSkillSummary { + name: string; + description: string; + tools?: string[]; + tags?: string[]; +} + +/** + * Format skills for compact /llm.txt output. + * Returns a plain text format suitable for LLM consumption. + * + * @param skills - Array of skill entries to format + * @returns Formatted plain text string + * + * @example Output format + * ``` + * # review-pr + * Review a GitHub pull request + * Tools: github_get_pr, github_add_comment + * Tags: github, code-review + * + * --- + * + * # deploy-app + * Deploy application to production + * Tools: docker_build, k8s_apply + * Tags: deployment, kubernetes + * ``` + */ +export function formatSkillsForLlmCompact(skills: SkillEntry[]): string { + const parts: string[] = []; + + for (const skill of skills) { + const lines: string[] = []; + + // Header with name + lines.push(`# ${skill.name}`); + + // Description + lines.push(skill.metadata.description); + + // Tools (if any) + const toolNames = skill.getToolNames(); + if (toolNames.length > 0) { + lines.push(`Tools: ${toolNames.join(', ')}`); + } + + // Tags (if any) + const tags = skill.metadata.tags; + if (tags && tags.length > 0) { + lines.push(`Tags: ${tags.join(', ')}`); + } + + parts.push(lines.join('\n')); + } + + return parts.join('\n\n---\n\n'); +} + +/** + * Format skills with full instructions AND tool schemas for /llm_full.txt. + * Loads full skill content and includes complete tool schemas. + * + * @param registry - Skill registry to load skills from + * @param toolRegistry - Tool registry to get tool schemas + * @param visibility - Optional visibility filter ('http' or 'both') + * @returns Formatted plain text with full skill details + */ +export async function formatSkillsForLlmFull( + registry: SkillRegistryInterfaceType, + toolRegistry: ToolRegistryInterface, + visibility: SkillVisibility = 'both', +): Promise { + const skills = registry.getSkills(false); // Don't include hidden + const parts: string[] = []; + + for (const skill of skills) { + // Filter by visibility + const skillVis = skill.metadata.visibility ?? 'both'; + if (visibility !== 'both') { + if (visibility === 'http' && skillVis === 'mcp') continue; + if (visibility === 'mcp' && skillVis === 'http') continue; + } + + const loaded = await registry.loadSkill(skill.name); + if (loaded) { + parts.push(formatSkillForLLMWithSchemas(loaded.skill, loaded.availableTools, loaded.missingTools, toolRegistry)); + } + } + + return parts.join('\n\n---\n\n'); +} + +/** + * Format a skill with FULL tool schemas (input/output) - not just names. + * Used by /llm_full.txt and enhanced loadSkill response. + * + * @param skill - The loaded skill content + * @param availableTools - List of available tool names + * @param missingTools - List of missing tool names + * @param toolRegistry - Tool registry for schema lookup + * @returns Formatted markdown string + */ +export function formatSkillForLLMWithSchemas( + skill: SkillContent, + availableTools: string[], + missingTools: string[], + toolRegistry: ToolRegistryInterface, +): string { + const parts: string[] = []; + + // Header + parts.push(`# Skill: ${skill.name}`); + parts.push(''); + parts.push(skill.description); + parts.push(''); + + // Warning if tools are missing + if (missingTools.length > 0) { + parts.push('> **Warning:** Some tools are not available:'); + parts.push(`> Missing: ${missingTools.join(', ')}`); + parts.push(''); + } + + // Tools section WITH FULL SCHEMAS + if (skill.tools.length > 0) { + parts.push('## Tools'); + parts.push(''); + + for (const tool of skill.tools) { + const isAvailable = availableTools.includes(tool.name); + const status = isAvailable ? '✓' : '✗'; + parts.push(`### [${status}] ${tool.name}`); + + if (tool.purpose) { + parts.push(`**Purpose:** ${tool.purpose}`); + } + + // Include full schema if tool is available + if (isAvailable) { + const toolEntry = toolRegistry.getTools(true).find((t) => t.name === tool.name); + if (toolEntry) { + const inputSchema = getToolInputSchema(toolEntry); + const outputSchema = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema; + + if (inputSchema) { + parts.push(''); + parts.push('**Input Schema:**'); + parts.push('```json'); + parts.push(JSON.stringify(inputSchema, null, 2)); + parts.push('```'); + } + + if (outputSchema) { + parts.push(''); + parts.push('**Output Schema:**'); + parts.push('```json'); + parts.push(JSON.stringify(outputSchema, null, 2)); + parts.push('```'); + } + } + } + parts.push(''); + } + } + + // Parameters section + if (skill.parameters && skill.parameters.length > 0) { + parts.push('## Parameters'); + parts.push(''); + for (const param of skill.parameters) { + const required = param.required ? ' (required)' : ''; + const desc = param.description ? `: ${param.description}` : ''; + parts.push(`- **${param.name}**${required}${desc}`); + } + parts.push(''); + } + + // Instructions + parts.push('## Instructions'); + parts.push(''); + parts.push(skill.instructions); + + // Examples section + if (skill.examples && skill.examples.length > 0) { + parts.push(''); + parts.push('## Examples'); + parts.push(''); + for (const example of skill.examples) { + parts.push(`### ${example.scenario}`); + if (example.expectedOutcome) { + parts.push(`Expected outcome: ${example.expectedOutcome}`); + } + parts.push(''); + } + } + + return parts.join('\n'); +} + +/** + * Get the input schema for a tool as JSON Schema. + * Delegates to ToolEntry.getInputJsonSchema() for single source of truth. + * + * @param tool - The tool entry + * @returns JSON Schema object or null if no schema is available + */ +function getToolInputSchema(tool: ToolEntry): Record | null { + return tool.getInputJsonSchema(); +} + +/** + * Skill API response structure. + */ +export interface SkillApiResponse { + id: string; + name: string; + description: string; + tags: string[]; + tools: string[]; + parameters?: Array<{ + name: string; + description?: string; + required: boolean; + type: string; + }>; + priority: number; + visibility: SkillVisibility; + availableTools?: string[]; + missingTools?: string[]; + isComplete?: boolean; +} + +/** + * Convert a skill entry to a JSON-serializable summary. + * Used by the /skills API endpoint. + * + * @param skill - The skill entry + * @param loadResult - Optional load result with tool availability info + * @returns JSON-serializable object + */ +export function skillToApiResponse( + skill: SkillEntry, + loadResult?: { + availableTools: string[]; + missingTools: string[]; + isComplete: boolean; + }, +): SkillApiResponse { + const result: SkillApiResponse = { + id: skill.metadata.id ?? skill.metadata.name, + name: skill.metadata.name, + description: skill.metadata.description, + tags: skill.metadata.tags ?? [], + tools: skill.getToolNames(), + parameters: skill.metadata.parameters?.map((p) => ({ + name: p.name, + description: p.description, + required: p.required ?? false, + type: p.type ?? 'string', + })), + priority: skill.metadata.priority ?? 0, + visibility: skill.metadata.visibility ?? 'both', + }; + + if (loadResult) { + result.availableTools = loadResult.availableTools; + result.missingTools = loadResult.missingTools; + result.isComplete = loadResult.isComplete; + } + + return result; +} + +/** + * Filter skills by visibility for a given context. + * + * @param skills - Array of skill entries + * @param context - The context requesting skills ('mcp' or 'http') + * @returns Filtered array of skills visible in the given context + */ +export function filterSkillsByVisibility(skills: SkillEntry[], context: 'mcp' | 'http'): SkillEntry[] { + return skills.filter((skill) => { + const visibility = skill.metadata.visibility ?? 'both'; + if (visibility === 'both') return true; + return visibility === context; + }); +} diff --git a/libs/sdk/src/skill/skill-mode.utils.ts b/libs/sdk/src/skill/skill-mode.utils.ts new file mode 100644 index 00000000..f50a3808 --- /dev/null +++ b/libs/sdk/src/skill/skill-mode.utils.ts @@ -0,0 +1,75 @@ +// file: libs/sdk/src/skill/skill-mode.utils.ts + +/** + * Utilities for skills-only mode detection. + * + * Skills-only mode is a special operational mode where: + * - The tools list returns empty (no tools exposed) + * - Only skill discovery tools are available + * - Used for planner agents that need skills but not execution tools + * + * @module skill/skill-mode.utils + */ + +/** + * Session payload interface for skills-only mode detection. + */ +export interface SkillsOnlySessionPayload { + skillsOnlyMode?: boolean; +} + +/** + * Detect if skills-only mode is requested from query parameters. + * + * Skills-only mode returns empty tools list, exposing only skill discovery. + * Clients can request this mode by adding `?mode=skills_only` to the connection URL. + * + * @param query - Query parameters from the request (may be undefined) + * @returns true if skills_only mode is requested + * + * @example + * ```typescript + * // In transport flow + * const query = request.query as Record | undefined; + * const skillsOnlyMode = detectSkillsOnlyMode(query); + * ``` + */ +export function detectSkillsOnlyMode(query: Record | undefined): boolean { + if (!query) return false; + + const mode = query['mode']; + + // Handle single value + if (mode === 'skills_only') { + return true; + } + + // Handle array of values (e.g., ?mode=skills_only&mode=other) + if (Array.isArray(mode) && mode.includes('skills_only')) { + return true; + } + + return false; +} + +/** + * Check if the current session is in skills-only mode. + * + * This checks the session payload for the skillsOnlyMode flag that was + * set during session creation based on the initial query parameters. + * + * @param sessionPayload - Session payload from authorization (may be undefined) + * @returns true if the session is in skills-only mode + * + * @example + * ```typescript + * // In tools/list flow + * const isSkillsOnly = isSkillsOnlySession(authorization.session?.payload); + * if (isSkillsOnly) { + * return { tools: [] }; // Return empty tools list + * } + * ``` + */ +export function isSkillsOnlySession(sessionPayload: SkillsOnlySessionPayload | undefined): boolean { + return sessionPayload?.skillsOnlyMode === true; +} diff --git a/libs/sdk/src/skill/skill-scope.helper.ts b/libs/sdk/src/skill/skill-scope.helper.ts new file mode 100644 index 00000000..f971508e --- /dev/null +++ b/libs/sdk/src/skill/skill-scope.helper.ts @@ -0,0 +1,125 @@ +// file: libs/sdk/src/skill/skill-scope.helper.ts + +/** + * Helper for registering skill capabilities in scope. + * + * This module extracts skill-specific registration logic from scope.instance.ts + * to maintain separation of concerns and improve maintainability. + * + * @module skill/skill-scope.helper + */ + +import type { FrontMcpLogger, EntryOwnerRef } from '../common'; +import type { SkillsConfigOptions } from '../common/types/options/skills-http'; +import type FlowRegistry from '../flows/flow.registry'; +import type ToolRegistry from '../tool/tool.registry'; +import type ProviderRegistry from '../provider/provider.registry'; +import type SkillRegistry from './skill.registry'; +import { SearchSkillsFlow, LoadSkillFlow, LlmTxtFlow, LlmFullTxtFlow, SkillsApiFlow } from './flows'; +import { getSkillTools } from './tools'; +import { normalizeTool } from '../tool/tool.utils'; +import { ToolInstance } from '../tool/tool.instance'; + +/** + * Options for registering skill capabilities. + */ +export interface SkillScopeRegistrationOptions { + /** Skill registry containing registered skills */ + skillRegistry: SkillRegistry; + /** Flow registry for registering skill flows */ + flowRegistry: FlowRegistry; + /** Tool registry for registering skill tools */ + toolRegistry: ToolRegistry; + /** Provider registry for dependency injection */ + providers: ProviderRegistry; + /** Skills configuration from @FrontMcp metadata */ + skillsConfig?: SkillsConfigOptions; + /** Logger instance for logging */ + logger: FrontMcpLogger; +} + +/** + * Register skill-related flows and tools in the scope. + * + * This function handles: + * - Registering MCP flows for skill discovery/loading (SearchSkillsFlow, LoadSkillFlow) + * - Registering skill MCP tools (searchSkills, loadSkill) unless disabled + * - Registering HTTP flows (llm.txt, llm_full.txt, /skills) when skillsConfig is enabled + * + * @param options - Registration options + * + * @example + * ```typescript + * await registerSkillCapabilities({ + * skillRegistry: this.scopeSkills, + * flowRegistry: this.scopeFlows, + * toolRegistry: this.scopeTools, + * providers: this.scopeProviders, + * skillsConfig: this.metadata.skillsConfig, + * logger: this.logger, + * }); + * ``` + */ +export async function registerSkillCapabilities(options: SkillScopeRegistrationOptions): Promise { + const { skillRegistry, flowRegistry, toolRegistry, providers, skillsConfig, logger } = options; + + // Early exit if no skills registered + if (!skillRegistry.hasAny()) { + return; + } + + // Always register MCP flows for skills + await flowRegistry.registryFlows([SearchSkillsFlow, LoadSkillFlow]); + + // Register skill MCP tools (searchSkills, loadSkill) unless disabled + const shouldRegisterMcpTools = skillsConfig?.mcpTools !== false; + + if (shouldRegisterMcpTools) { + await registerSkillMcpTools({ toolRegistry, providers, logger }); + } else { + logger.verbose('Skill MCP tools disabled via skillsConfig.mcpTools=false'); + } + + // Register HTTP flows if skillsConfig is enabled + if (skillsConfig?.enabled) { + await flowRegistry.registryFlows([LlmTxtFlow, LlmFullTxtFlow, SkillsApiFlow]); + logger.verbose('Registered skills HTTP flows (llm.txt, llm_full.txt, /skills API)'); + } +} + +/** + * Register skill MCP tools in the tool registry. + * + * @internal + */ +async function registerSkillMcpTools(options: { + toolRegistry: ToolRegistry; + providers: ProviderRegistry; + logger: FrontMcpLogger; +}): Promise { + const { toolRegistry, providers, logger } = options; + const skillTools = getSkillTools(); + + const ownerRef: EntryOwnerRef = { + kind: 'scope', + id: '_skills', + ref: undefined as unknown as new (...args: unknown[]) => unknown, + }; + + for (const SkillToolClass of skillTools) { + try { + const toolRecord = normalizeTool(SkillToolClass); + + // Update owner ref for each tool + ownerRef.ref = SkillToolClass; + + const toolEntry = new ToolInstance(toolRecord, providers, ownerRef); + await toolEntry.ready; + + toolRegistry.registerToolInstance(toolEntry); + logger.verbose(`Registered skill tool: ${toolRecord.metadata.name}`); + } catch (error) { + logger.warn(`Failed to register skill tool: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/libs/sdk/src/skill/skill.instance.ts b/libs/sdk/src/skill/skill.instance.ts index d6f04372..5b6dfc0b 100644 --- a/libs/sdk/src/skill/skill.instance.ts +++ b/libs/sdk/src/skill/skill.instance.ts @@ -2,6 +2,7 @@ import { EntryOwnerRef, SkillEntry, SkillKind, SkillRecord, SkillToolRef, normalizeToolRef } from '../common'; import { SkillContent } from '../common/interfaces'; +import { SkillVisibility } from '../common/metadata/skill.metadata'; import ProviderRegistry from '../provider/provider.registry'; import { Scope } from '../scope'; import { loadInstructions, buildSkillContent } from './skill.utils'; @@ -14,6 +15,7 @@ interface CachedSkillContent extends SkillContent { tags?: string[]; priority?: number; hideFromDiscovery?: boolean; + visibility?: SkillVisibility; } /** @@ -26,42 +28,46 @@ interface CachedSkillContent extends SkillContent { */ export class SkillInstance extends SkillEntry { /** The provider registry this skill is bound to */ - private readonly _providers: ProviderRegistry; + private readonly providersRef: ProviderRegistry; /** The scope this skill operates in */ readonly scope: Scope; /** Cached instructions (loaded lazily) */ - private _cachedInstructions?: string; + private cachedInstructions?: string; /** Cached skill content (built lazily) */ - private _cachedContent?: CachedSkillContent; + private cachedContent?: CachedSkillContent; /** Tags for search indexing */ - private readonly _tags: string[]; + private readonly tags: string[]; /** Priority for search ranking */ - private readonly _priority: number; + private readonly priority: number; /** Whether skill is hidden from discovery */ - private readonly _hidden: boolean; + private readonly hidden: boolean; + + /** Visibility mode for skill discovery */ + private readonly skillVisibility: SkillVisibility; constructor(record: SkillRecord, providers: ProviderRegistry, owner: EntryOwnerRef) { super(record); this.owner = owner; - this._providers = providers; + this.providersRef = providers; // Set name and fullName this.name = record.metadata.id ?? record.metadata.name; this.fullName = `${this.owner.id}:${this.name}`; // Cache metadata properties for faster access - this._tags = record.metadata.tags ?? []; - this._priority = record.metadata.priority ?? 0; - this._hidden = record.metadata.hideFromDiscovery ?? false; + this.tags = record.metadata.tags ?? []; + this.priority = record.metadata.priority ?? 0; + this.hidden = record.metadata.hideFromDiscovery ?? false; + this.skillVisibility = record.metadata.visibility ?? 'both'; // Get scope reference - this.scope = this._providers.getActiveScope(); + this.scope = this.providersRef.getActiveScope(); // Start initialization this.ready = this.initialize(); @@ -86,8 +92,8 @@ export class SkillInstance extends SkillEntry { * Results are cached after the first load. */ override async loadInstructions(): Promise { - if (this._cachedInstructions !== undefined) { - return this._cachedInstructions; + if (this.cachedInstructions !== undefined) { + return this.cachedInstructions; } // Determine base path for file resolution @@ -100,8 +106,8 @@ export class SkillInstance extends SkillEntry { } // Load instructions from source - this._cachedInstructions = await loadInstructions(this.metadata.instructions, basePath); - return this._cachedInstructions; + this.cachedInstructions = await loadInstructions(this.metadata.instructions, basePath); + return this.cachedInstructions; } /** @@ -109,22 +115,23 @@ export class SkillInstance extends SkillEntry { * Results are cached after the first load. */ override async load(): Promise { - if (this._cachedContent !== undefined) { - return this._cachedContent; + if (this.cachedContent !== undefined) { + return this.cachedContent; } const instructions = await this.loadInstructions(); const baseContent = buildSkillContent(this.metadata, instructions); // Add additional metadata that's useful for search but not in base SkillContent - this._cachedContent = { + this.cachedContent = { ...baseContent, - tags: this._tags, - priority: this._priority, - hideFromDiscovery: this._hidden, + tags: this.tags, + priority: this.priority, + hideFromDiscovery: this.hidden, + visibility: this.skillVisibility, }; - return this._cachedContent; + return this.cachedContent; } /** @@ -147,36 +154,36 @@ export class SkillInstance extends SkillEntry { * Get the skill's tags. */ override getTags(): string[] { - return this._tags; + return this.tags; } /** * Check if the skill is hidden from discovery. */ override isHidden(): boolean { - return this._hidden; + return this.hidden; } /** * Get the skill's priority for search ranking. */ override getPriority(): number { - return this._priority; + return this.priority; } /** * Get the provider registry. */ get providers(): ProviderRegistry { - return this._providers; + return this.providersRef; } /** * Clear cached content (useful for hot-reload scenarios). */ clearCache(): void { - this._cachedInstructions = undefined; - this._cachedContent = undefined; + this.cachedInstructions = undefined; + this.cachedContent = undefined; } /** @@ -187,8 +194,8 @@ export class SkillInstance extends SkillEntry { * @returns SkillContent if available synchronously, undefined otherwise */ getContentSync(): SkillContent | undefined { - if (this._cachedContent) { - return this._cachedContent; + if (this.cachedContent) { + return this.cachedContent; } // Only works with inline instructions diff --git a/libs/sdk/src/skill/skill.registry.ts b/libs/sdk/src/skill/skill.registry.ts index b2cb2344..d12620ff 100644 --- a/libs/sdk/src/skill/skill.registry.ts +++ b/libs/sdk/src/skill/skill.registry.ts @@ -61,6 +61,25 @@ export interface SkillRegistryOptions { failOnInvalidSkills?: boolean; } +/** + * Options for getting skills from the registry. + */ +export interface GetSkillsOptions { + /** + * Whether to include hidden skills. + * @default false + */ + includeHidden?: boolean; + + /** + * Filter by visibility context. + * - 'mcp': Only skills visible via MCP (visibility = 'mcp' or 'both') + * - 'http': Only skills visible via HTTP (visibility = 'http' or 'both') + * - 'all': All skills regardless of visibility (default) + */ + visibility?: 'mcp' | 'http' | 'all'; +} + /** * Interface for SkillRegistry consumers. */ @@ -69,9 +88,9 @@ export interface SkillRegistryInterface { /** * Get all skills in the registry. - * @param includeHidden - Whether to include hidden skills + * @param options - Options for filtering skills (or boolean for backwards compatibility) */ - getSkills(includeHidden?: boolean): SkillEntry[]; + getSkills(options?: boolean | GetSkillsOptions): SkillEntry[]; /** * Find a skill by name. @@ -380,10 +399,31 @@ export default class SkillRegistry /** * Get all skills in the registry. + * @param options - Options for filtering skills (or boolean for backwards compatibility) */ - getSkills(includeHidden = false): SkillEntry[] { - const all = this.listAllIndexed().map((r) => r.instance); - return includeHidden ? all : all.filter((s) => !s.isHidden()); + getSkills(options?: boolean | GetSkillsOptions): SkillEntry[] { + // Handle backwards compatibility with boolean argument + const opts: GetSkillsOptions = typeof options === 'boolean' ? { includeHidden: options } : (options ?? {}); + + const { includeHidden = false, visibility = 'all' } = opts; + + let skills = this.listAllIndexed().map((r) => r.instance); + + // Filter by hidden status + if (!includeHidden) { + skills = skills.filter((s) => !s.isHidden()); + } + + // Filter by visibility + if (visibility !== 'all') { + skills = skills.filter((s) => { + const skillVis = s.metadata.visibility ?? 'both'; + if (skillVis === 'both') return true; + return skillVis === visibility; + }); + } + + return skills; } /** diff --git a/libs/sdk/src/skill/skill.utils.ts b/libs/sdk/src/skill/skill.utils.ts index 02f21a3c..588b8484 100644 --- a/libs/sdk/src/skill/skill.utils.ts +++ b/libs/sdk/src/skill/skill.utils.ts @@ -282,3 +282,80 @@ export function formatSkillForLLM(skill: SkillContent, availableTools: string[], return parts.join('\n'); } + +/** + * Generate next steps guidance after loading skills. + * + * @param skills - Array of loaded skills with their tool availability + * @param allToolsAvailable - Whether all tools across all skills are available + * @returns Human-readable guidance string + */ +export function generateNextSteps( + skills: Array<{ name: string; isComplete: boolean; tools: Array<{ name: string; available: boolean }> }>, + allToolsAvailable: boolean, +): string { + if (skills.length === 0) { + return 'No skills were loaded. Try searchSkills to find available skills.'; + } + + if (!allToolsAvailable) { + const missingToolSkills = skills.filter((s) => !s.isComplete); + return ( + `Some tools are missing for: ${missingToolSkills.map((s) => s.name).join(', ')}. ` + + 'You can still follow the instructions but may need to skip or adapt steps that use unavailable tools. ' + + 'Check the tools[] array in each skill to see which tools are available.' + ); + } + + if (skills.length === 1) { + const skill = skills[0]; + return ( + `Ready to execute "${skill.name}". ` + + 'Read the formattedContent for step-by-step instructions. ' + + `The skill uses ${skill.tools.length} tool(s) - see their schemas in tools[].inputSchema to understand the expected parameters.` + ); + } + + return ( + `Loaded ${skills.length} skills. Review each skill's instructions in formattedContent. ` + + 'You can combine workflows by following instructions from multiple skills. ' + + 'Check tools[].inputSchema for each tool to understand the expected parameters.' + ); +} + +/** + * Generate search guidance based on results. + * + * @param skills - Array of search results with executability info + * @param query - The original search query + * @returns Human-readable guidance string + */ +export function generateSearchGuidance( + skills: Array<{ name: string; score: number; canExecute: boolean }>, + query: string, +): string { + if (skills.length === 0) { + return ( + `No skills found for "${query}". ` + + 'Try a different search query, or the MCP server may not have skills for this task. ' + + 'You can still use individual tools directly - check the available tools list.' + ); + } + + const executableSkills = skills.filter((s) => s.canExecute); + const topSkill = skills[0]; + + if (executableSkills.length > 0) { + const topExecutable = executableSkills[0]; + return ( + `Found ${skills.length} skill(s). Recommended: loadSkills({ skillIds: ["${topExecutable.name}"] }) ` + + `to get full instructions. ${executableSkills.length} skill(s) have all tools available.` + ); + } + + return ( + `Found ${skills.length} skill(s), but some tools are missing. ` + + `Try loadSkills({ skillIds: ["${topSkill.name}"] }) to see which tools are available. ` + + 'You may be able to partially execute the workflow.' + ); +} diff --git a/libs/sdk/src/skill/tools/index.ts b/libs/sdk/src/skill/tools/index.ts index 3462697d..afc6f546 100644 --- a/libs/sdk/src/skill/tools/index.ts +++ b/libs/sdk/src/skill/tools/index.ts @@ -10,14 +10,19 @@ */ import { SearchSkillsTool } from './search-skills.tool'; -import { LoadSkillTool } from './load-skill.tool'; +import { LoadSkillsTool, LoadSkillTool } from './load-skills.tool'; -export { SearchSkillsTool, LoadSkillTool }; +export { SearchSkillsTool, LoadSkillsTool }; + +/** + * @deprecated Use LoadSkillsTool instead + */ +export { LoadSkillTool }; /** * Get all skill-related tools. * Used by the SDK to register skill tools when skills are available. */ export function getSkillTools() { - return [SearchSkillsTool, LoadSkillTool]; + return [SearchSkillsTool, LoadSkillsTool]; } diff --git a/libs/sdk/src/skill/tools/load-skill.tool.ts b/libs/sdk/src/skill/tools/load-skill.tool.ts deleted file mode 100644 index 5fa7b1c1..00000000 --- a/libs/sdk/src/skill/tools/load-skill.tool.ts +++ /dev/null @@ -1,135 +0,0 @@ -// file: libs/sdk/src/skill/tools/load-skill.tool.ts - -import { z } from 'zod'; -import { Tool, ToolContext } from '../../common'; -import { formatSkillForLLM } from '../skill.utils'; - -/** - * Input schema for loadSkill tool. - */ -const inputSchema = { - skillId: z.string().min(1).describe('ID or name of the skill to load'), - format: z - .enum(['full', 'instructions-only']) - .default('full') - .describe('Output format: full (all details) or instructions-only (just the workflow steps)'), -}; - -/** - * Output schema for loadSkill tool. - */ -const outputSchema = { - skill: z.object({ - id: z.string(), - name: z.string(), - description: z.string(), - instructions: z.string(), - tools: z.array( - z.object({ - name: z.string(), - purpose: z.string().optional(), - available: z.boolean(), - }), - ), - parameters: z - .array( - z.object({ - name: z.string(), - description: z.string().optional(), - required: z.boolean().optional(), - type: z.string().optional(), - }), - ) - .optional(), - }), - availableTools: z.array(z.string()), - missingTools: z.array(z.string()), - isComplete: z.boolean(), - warning: z.string().optional(), - formattedContent: z.string().describe('Formatted skill content ready for LLM consumption'), -}; - -type Input = z.infer>; -type Output = z.infer>; - -/** - * Tool for loading a skill's full content. - * - * This tool retrieves a skill's instructions, tool requirements, and parameters. - * Use this after searching for skills to get the detailed workflow guide. - * - * @example - * ```typescript - * // Load a skill by ID - * const result = await loadSkill({ skillId: 'review-pr' }); - * - * // Get instructions only (shorter response) - * const result = await loadSkill({ skillId: 'deploy-app', format: 'instructions-only' }); - * ``` - */ -@Tool({ - name: 'loadSkill', - description: - 'Load the full content of a skill by its ID or name. ' + - 'Returns detailed instructions, required tools, and parameters. ' + - 'Use this after finding a relevant skill with searchSkills.', - inputSchema, - outputSchema, - tags: ['skills', 'workflow'], - annotations: { - title: 'Load Skill', - readOnlyHint: true, - }, -}) -export class LoadSkillTool extends ToolContext { - async execute(input: Input): Promise { - const skillRegistry = this.scope.skills; - - if (!skillRegistry) { - this.fail(new Error('Skills are not available in this scope')); - } - - // Load the skill - const result = await skillRegistry.loadSkill(input.skillId); - - if (!result) { - this.fail(new Error(`Skill "${input.skillId}" not found`)); - } - - const { skill, availableTools, missingTools, isComplete, warning } = result; - - // Build tools array with availability info - const tools = skill.tools.map((t) => ({ - name: t.name, - purpose: t.purpose, - available: availableTools.includes(t.name), - })); - - // Format content for LLM - const formattedContent = - input.format === 'instructions-only' - ? skill.instructions - : formatSkillForLLM(skill, availableTools, missingTools); - - return { - skill: { - id: skill.id, - name: skill.name, - description: skill.description, - instructions: skill.instructions, - tools, - parameters: skill.parameters?.map((p) => ({ - name: p.name, - description: p.description, - required: p.required, - type: p.type, - })), - }, - availableTools, - missingTools, - isComplete, - warning, - formattedContent, - }; - } -} diff --git a/libs/sdk/src/skill/tools/load-skills.tool.ts b/libs/sdk/src/skill/tools/load-skills.tool.ts new file mode 100644 index 00000000..170cb3f5 --- /dev/null +++ b/libs/sdk/src/skill/tools/load-skills.tool.ts @@ -0,0 +1,251 @@ +// file: libs/sdk/src/skill/tools/load-skills.tool.ts + +import { z } from 'zod'; +import { Tool, ToolContext } from '../../common'; +import { formatSkillForLLM, generateNextSteps } from '../skill.utils'; +import { formatSkillForLLMWithSchemas } from '../skill-http.utils'; +import type { ToolRegistryInterface } from '../../common'; + +/** + * Input schema for loadSkills tool. + */ +const inputSchema = { + skillIds: z + .array(z.string().min(1)) + .min(1) + .max(5) + .describe( + 'Array of skill IDs or names to load. Load one skill when you know exactly what you need, ' + + 'or load multiple related skills (up to 5) to combine their workflows.', + ), + format: z + .enum(['full', 'instructions-only']) + .default('full') + .describe( + 'Output format: full (all details including tool schemas) or instructions-only (just the workflow steps)', + ), +}; + +/** + * Tool info with optional schemas. + */ +interface ToolInfo { + name: string; + purpose?: string; + available: boolean; + inputSchema?: unknown; + outputSchema?: unknown; +} + +/** + * Single skill result structure. + */ +const skillResultSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + instructions: z.string(), + tools: z.array( + z.object({ + name: z.string(), + purpose: z.string().optional(), + available: z.boolean(), + inputSchema: z.unknown().optional().describe('JSON Schema for tool input parameters'), + outputSchema: z.unknown().optional().describe('JSON Schema for tool output'), + }), + ), + parameters: z + .array( + z.object({ + name: z.string(), + description: z.string().optional(), + required: z.boolean().optional(), + type: z.string().optional(), + }), + ) + .optional(), + availableTools: z.array(z.string()), + missingTools: z.array(z.string()), + isComplete: z.boolean(), + warning: z.string().optional(), + formattedContent: z.string().describe('Formatted skill content with tool schemas for LLM consumption'), +}); + +/** + * Output schema for loadSkills tool. + */ +const outputSchema = { + skills: z.array(skillResultSchema), + summary: z.object({ + totalSkills: z.number(), + totalTools: z.number(), + allToolsAvailable: z.boolean(), + combinedWarnings: z.array(z.string()).optional(), + }), + nextSteps: z.string().describe('Guidance on what to do next with the loaded skills'), +}; + +type Input = z.infer>; +type Output = z.infer>; + +/** + * Tool for loading one or more skills' full content. + * + * This tool retrieves skill instructions, tool requirements, and parameters. + * Use this after searching for skills to get the detailed workflow guides. + * + * @example + * ```typescript + * // Load a single skill by ID + * const result = await loadSkills({ skillIds: ['review-pr'] }); + * + * // Load multiple related skills + * const result = await loadSkills({ skillIds: ['review-pr', 'suggest-fixes'] }); + * + * // Get instructions only (shorter response) + * const result = await loadSkills({ skillIds: ['deploy-app'], format: 'instructions-only' }); + * ``` + */ +@Tool({ + name: 'loadSkills', + description: + 'Load the complete workflow details for one or more skills. ' + + 'This tool returns everything you need to execute a skill:\n\n' + + '**What you get:**\n' + + '- Step-by-step instructions for each skill\n' + + '- List of MCP tools used by each skill with their input/output schemas\n' + + '- Parameters needed to customize the workflow\n' + + '- Availability status for each tool (available/missing)\n\n' + + '**When to use:**\n' + + '- After finding a relevant skill with searchSkills\n' + + '- When combining multiple related skills (e.g., "review-pr" + "suggest-fixes")\n' + + '- When you need the full tool schemas to understand how to call tools\n\n' + + '**Output format:**\n' + + '- formattedContent: Markdown-formatted instructions ready to follow\n' + + '- tools[].inputSchema: JSON Schema showing exactly what parameters each tool expects\n' + + '- nextSteps: Suggested actions after loading\n\n' + + '**Example flow:**\n' + + '1. searchSkills({ query: "deploy to kubernetes" })\n' + + '2. loadSkills({ skillIds: ["k8s-deploy", "health-check"] })\n' + + '3. Follow the instructions, calling the listed tools with the schemas provided', + inputSchema, + outputSchema, + tags: ['skills', 'workflow', 'entry-point'], + annotations: { + title: 'Load Skills', + readOnlyHint: true, + }, +}) +export class LoadSkillsTool extends ToolContext { + async execute(input: Input): Promise { + const skillRegistry = this.scope.skills; + + if (!skillRegistry) { + this.fail(new Error('Skills are not available in this scope')); + } + + const results: z.infer[] = []; + const allWarnings: string[] = []; + let totalTools = 0; + let allToolsAvailable = true; + + // Get tool registry for schemas + const toolRegistry: ToolRegistryInterface | undefined = this.scope.tools; + + for (const skillId of input.skillIds) { + const result = await skillRegistry.loadSkill(skillId); + if (!result) { + allWarnings.push(`Skill "${skillId}" not found`); + continue; + } + + const { skill, availableTools, missingTools, isComplete, warning } = result; + + // Build tools array with availability and schemas + const tools: ToolInfo[] = skill.tools.map((t) => { + const isAvailable = availableTools.includes(t.name); + const toolResult: ToolInfo = { + name: t.name, + purpose: t.purpose, + available: isAvailable, + }; + + // Include schemas for available tools + if (isAvailable && toolRegistry) { + const toolEntry = toolRegistry.getTools(true).find((te) => te.name === t.name); + if (toolEntry) { + const rawInput = toolEntry.getInputJsonSchema?.() ?? toolEntry.rawInputSchema; + if (rawInput) { + toolResult.inputSchema = rawInput; + } + const rawOutput = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema; + if (rawOutput) { + toolResult.outputSchema = rawOutput; + } + } + } + + return toolResult; + }); + + totalTools += tools.length; + if (missingTools.length > 0) allToolsAvailable = false; + if (warning) allWarnings.push(warning); + + // Format content + let formattedContent: string; + if (input.format === 'instructions-only') { + formattedContent = skill.instructions; + } else if (toolRegistry) { + formattedContent = formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry); + } else { + formattedContent = formatSkillForLLM(skill, availableTools, missingTools); + } + + results.push({ + id: skill.id, + name: skill.name, + description: skill.description, + instructions: skill.instructions, + tools, + parameters: skill.parameters?.map((p) => ({ + name: p.name, + description: p.description, + required: p.required, + type: p.type, + })), + availableTools, + missingTools, + isComplete, + warning, + formattedContent, + }); + } + + // Generate next steps guidance + const nextSteps = generateNextSteps( + results.map((r) => ({ + name: r.name, + isComplete: r.isComplete, + tools: r.tools, + })), + allToolsAvailable, + ); + + return { + skills: results, + summary: { + totalSkills: results.length, + totalTools, + allToolsAvailable, + combinedWarnings: allWarnings.length > 0 ? allWarnings : undefined, + }, + nextSteps, + }; + } +} + +/** + * @deprecated Use LoadSkillsTool instead + */ +export { LoadSkillsTool as LoadSkillTool }; diff --git a/libs/sdk/src/skill/tools/search-skills.tool.ts b/libs/sdk/src/skill/tools/search-skills.tool.ts index a9b57354..28d3e75a 100644 --- a/libs/sdk/src/skill/tools/search-skills.tool.ts +++ b/libs/sdk/src/skill/tools/search-skills.tool.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { Tool, ToolContext, normalizeToolRef } from '../../common'; import { SkillSearchResult } from '../skill-storage.interface'; +import { generateSearchGuidance } from '../skill.utils'; /** * Input schema for searchSkills tool. @@ -24,19 +25,21 @@ const outputSchema = { id: z.string(), name: z.string(), description: z.string(), - score: z.number(), + score: z.number().describe('Relevance score (0-1), higher means better match'), tags: z.array(z.string()).optional(), tools: z.array( z.object({ name: z.string(), - available: z.boolean(), + available: z.boolean().describe('Whether this tool is available on this server'), }), ), source: z.enum(['local', 'external']), + canExecute: z.boolean().describe('True if all required tools are available'), }), ), total: z.number(), hasMore: z.boolean(), + guidance: z.string().describe('Suggested next action based on search results'), }; type Input = z.infer>; @@ -63,12 +66,29 @@ type Output = z.infer>; @Tool({ name: 'searchSkills', description: - 'Search for skills that can help with multi-step tasks. ' + - 'Skills are workflow guides that combine multiple tools. ' + - 'Use this to find relevant skills before starting complex tasks.', + 'Discover available skills on this MCP server. Skills are pre-built workflows that guide you through ' + + "complex multi-step tasks using the server's tools.\n\n" + + '**This is the recommended starting point** when you need to:\n' + + "- Accomplish a task you haven't done before on this server\n" + + '- Find the right combination of tools for a complex workflow\n' + + '- Learn what capabilities this MCP server offers\n\n' + + '**How skills work:**\n' + + '1. Search for skills matching your goal (this tool)\n' + + '2. Load the full skill details with loadSkills\n' + + '3. Follow the step-by-step instructions, calling the tools listed\n\n' + + '**Search tips:**\n' + + '- Use natural language: "review a pull request", "deploy to production"\n' + + '- Filter by tags: tags: ["github", "devops"]\n' + + '- Filter by required tools: tools: ["git_commit", "git_push"]\n' + + '- Set requireAllTools: true to only see skills you can fully execute\n\n' + + '**Output explained:**\n' + + '- score: Relevance to your query (higher is better)\n' + + "- tools[].available: Whether you can use this tool (true) or it's missing (false)\n" + + '- canExecute: True if all tools are available for this skill\n' + + '- Use the skill id or name with loadSkills to get full instructions', inputSchema, outputSchema, - tags: ['skills', 'discovery'], + tags: ['skills', 'discovery', 'entry-point'], annotations: { title: 'Search Skills', readOnlyHint: true, @@ -83,6 +103,7 @@ export class SearchSkillsTool extends ToolContext { + const visibility = result.metadata.visibility ?? 'both'; + return visibility === 'mcp' || visibility === 'both'; + }); + // Transform results to output format - const skills = results.map((result: SkillSearchResult) => ({ - id: result.metadata.id ?? result.metadata.name, - name: result.metadata.name, - description: result.metadata.description, - score: result.score, - tags: result.metadata.tags, - tools: (result.metadata.tools ?? []).map((t) => { + const skills = mcpVisibleResults.map((result: SkillSearchResult) => { + const tools = (result.metadata.tools ?? []).map((t) => { // Use normalizeToolRef to correctly handle all tool reference types // including class-based refs where t.name would be the class name try { @@ -118,9 +140,19 @@ export class SearchSkillsTool extends ToolContext t.available), + }; + }); // Pagination info: // - total: number of results returned (search already filtered by query/tags/tools) @@ -128,10 +160,17 @@ export class SearchSkillsTool extends ToolContext= input.limit; + // Generate guidance based on results + const guidance = generateSearchGuidance( + skills.map((s) => ({ name: s.name, score: s.score, canExecute: s.canExecute })), + input.query, + ); + return { skills, total, hasMore, + guidance, }; } } diff --git a/libs/sdk/src/tool/flows/tools-list.flow.ts b/libs/sdk/src/tool/flows/tools-list.flow.ts index dab4852e..44a42cca 100644 --- a/libs/sdk/src/tool/flows/tools-list.flow.ts +++ b/libs/sdk/src/tool/flows/tools-list.flow.ts @@ -249,6 +249,15 @@ export default class ToolsListFlow extends FlowBase { this.logger.info('findTools:start'); try { + // Check for skills-only mode - return empty tools array + const { authInfo } = this.state.required; + if (authInfo.sessionIdPayload?.skillsOnlyMode) { + this.logger.info('findTools: skills-only mode - returning empty tools array'); + this.state.set('tools', []); + this.logger.verbose('findTools:done (skills-only mode)'); + return; + } + const apps = this.scope.apps.getApps(); this.logger.info(`findTools: discovered ${apps.length} app(s)`); @@ -257,7 +266,6 @@ export default class ToolsListFlow extends FlowBase { // Get elicitation support from session payload (set during MCP initialize) // authInfo is guaranteed by parseInput (throws if missing for authorized flow) - const { authInfo } = this.state.required; const supportsElicitation = authInfo.sessionIdPayload?.supportsElicitation; // Get tools appropriate for this client's elicitation support diff --git a/libs/sdk/src/transport/flows/handle.sse.flow.ts b/libs/sdk/src/transport/flows/handle.sse.flow.ts index c10a9e71..944c7b84 100644 --- a/libs/sdk/src/transport/flows/handle.sse.flow.ts +++ b/libs/sdk/src/transport/flows/handle.sse.flow.ts @@ -17,6 +17,7 @@ import { import { z } from 'zod'; import { Scope } from '../../scope'; import { createSessionId } from '../../auth/session/utils/session-id.utils'; +import { detectSkillsOnlyMode } from '../../skill/skill-mode.utils'; export const plan = { pre: ['parseInput', 'router'], @@ -139,9 +140,14 @@ export default class HandleSseFlow extends FlowBase { session = authorization.session; } else { // No session - create new one (initialize request) + // Detect skills_only mode from query params + const query = request.query as Record | undefined; + const skillsOnlyMode = detectSkillsOnlyMode(query); + session = createSessionId('legacy-sse', token, { userAgent: request.headers?.['user-agent'] as string | undefined, platformDetectionConfig: (this.scope as Scope).metadata.transport?.platformDetection, + skillsOnlyMode, }); } diff --git a/libs/sdk/src/transport/flows/handle.streamable-http.flow.ts b/libs/sdk/src/transport/flows/handle.streamable-http.flow.ts index 9a2f5d26..b85d381b 100644 --- a/libs/sdk/src/transport/flows/handle.streamable-http.flow.ts +++ b/libs/sdk/src/transport/flows/handle.streamable-http.flow.ts @@ -16,6 +16,7 @@ import { z } from 'zod'; import { ElicitResultSchema, RequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { Scope } from '../../scope'; import { createSessionId } from '../../auth/session/utils/session-id.utils'; +import { detectSkillsOnlyMode } from '../../skill/skill-mode.utils'; export const plan = { pre: ['parseInput', 'router'], @@ -113,9 +114,14 @@ export default class HandleStreamableHttpFlow extends FlowBase { session = authorization.session; } else { // No session - create new one (initialize request) + // Detect skills_only mode from query params + const query = request.query as Record | undefined; + const skillsOnlyMode = detectSkillsOnlyMode(query); + session = createSessionId('streamable-http', token, { userAgent: request.headers?.['user-agent'] as string | undefined, platformDetectionConfig: (this.scope as Scope).metadata.transport?.platformDetection, + skillsOnlyMode, }); } diff --git a/libs/testing/src/client/mcp-test-client.builder.ts b/libs/testing/src/client/mcp-test-client.builder.ts index f4ac1c34..7ad5aff1 100644 --- a/libs/testing/src/client/mcp-test-client.builder.ts +++ b/libs/testing/src/client/mcp-test-client.builder.ts @@ -163,6 +163,22 @@ export class McpTestClientBuilder { return this; } + /** + * Set query parameters to append to the connection URL. + * Useful for testing mode switches like `?mode=skills_only`. + * + * @example + * ```typescript + * const client = await McpTestClient.create({ baseUrl }) + * .withQueryParams({ mode: 'skills_only' }) + * .buildAndConnect(); + * ``` + */ + withQueryParams(params: Record): this { + this.config.queryParams = { ...this.config.queryParams, ...params }; + return this; + } + /** * Build the McpTestClient instance (does not connect) */ diff --git a/libs/testing/src/client/mcp-test-client.ts b/libs/testing/src/client/mcp-test-client.ts index c1cf4277..e891ff13 100644 --- a/libs/testing/src/client/mcp-test-client.ts +++ b/libs/testing/src/client/mcp-test-client.ts @@ -61,9 +61,9 @@ const DEFAULT_CLIENT_INFO = { // ═══════════════════════════════════════════════════════════════════ export class McpTestClient { - // Platform and capabilities are optional - only set when testing platform-specific behavior - private readonly config: Required> & - Pick; + // Platform, capabilities, and queryParams are optional - only set when needed + private readonly config: Required> & + Pick; private transport: McpTransport | null = null; private initResult: InitializeResult | null = null; private requestIdCounter = 0; @@ -100,6 +100,7 @@ export class McpTestClient { clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO, platform: config.platform, capabilities: config.capabilities, + queryParams: config.queryParams, }; // If a token is provided, user is authenticated (even in public mode) @@ -870,10 +871,17 @@ export class McpTestClient { // ═══════════════════════════════════════════════════════════════════ private createTransport(): McpTransport { + // Build URL with query params if provided + let baseUrl = this.config.baseUrl; + if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) { + const params = new URLSearchParams(this.config.queryParams); + baseUrl = `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${params.toString()}`; + } + switch (this.config.transport) { case 'streamable-http': return new StreamableHttpTransport({ - baseUrl: this.config.baseUrl, + baseUrl, timeout: this.config.timeout, auth: this.config.auth, publicMode: this.config.publicMode, diff --git a/libs/testing/src/client/mcp-test-client.types.ts b/libs/testing/src/client/mcp-test-client.types.ts index 90f36b95..6cc99170 100644 --- a/libs/testing/src/client/mcp-test-client.types.ts +++ b/libs/testing/src/client/mcp-test-client.types.ts @@ -186,6 +186,16 @@ export interface McpTestClientConfig { * - Others: Uses frontmcp/* + ui/* keys for compatibility */ platform?: TestPlatformType; + /** + * Query parameters to append to the connection URL. + * Useful for testing mode switches like `?mode=skills_only`. + * + * Example: + * ```typescript + * queryParams: { mode: 'skills_only' } + * ``` + */ + queryParams?: Record; } // ═══════════════════════════════════════════════════════════════════ From 3fd42167e19c9aef6aa995b23ef7bc334ec9eb50 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 26 Jan 2026 00:54:59 +0200 Subject: [PATCH 2/5] feat: Refactor loadSkill to loadSkills for multi-skill support and update related tests --- .../e2e/load-skill.e2e.test.ts | 230 +++++++++------- .../e2e/multi-skill-loading.e2e.test.ts | 223 ++++++++------- .../e2e/plugin-skills.e2e.test.ts | 172 ++++++------ .../e2e/skill-session.e2e.test.ts | 255 ++++++++---------- .../e2e/skills-only-mode.e2e.test.ts | 44 +-- .../e2e/tool-authorization.e2e.test.ts | 186 +++++++------ libs/sdk/src/skill/flows/load-skill.flow.ts | 5 +- libs/sdk/src/skill/tools/load-skills.tool.ts | 9 +- .../src/transport/flows/handle.sse.flow.ts | 1 - 9 files changed, 600 insertions(+), 525 deletions(-) diff --git a/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts index c3525682..ad128102 100644 --- a/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts @@ -1,5 +1,5 @@ /** - * E2E Tests for loadSkill Tool + * E2E Tests for loadSkills Tool * * Tests skill loading functionality: * - Loading skill by ID/name @@ -24,15 +24,13 @@ interface SkillParameter { type?: string; } -interface LoadSkillResult { - skill: { - id: string; - name: string; - description: string; - instructions: string; - tools: SkillTool[]; - parameters?: SkillParameter[]; - }; +interface SkillResult { + id: string; + name: string; + description: string; + instructions: string; + tools: SkillTool[]; + parameters?: SkillParameter[]; availableTools: string[]; missingTools: string[]; isComplete: boolean; @@ -40,7 +38,18 @@ interface LoadSkillResult { formattedContent: string; } -test.describe('loadSkill E2E', () => { +interface LoadSkillsResult { + skills: SkillResult[]; + summary: { + totalSkills: number; + totalTools: number; + allToolsAvailable: boolean; + combinedWarnings?: string[]; + }; + nextSteps: string; +} + +test.describe('loadSkills E2E', () => { test.use({ server: 'apps/e2e/demo-e2e-skills/src/main.ts', project: 'demo-e2e-skills', @@ -49,125 +58,133 @@ test.describe('loadSkill E2E', () => { test.describe('Load Skill by ID', () => { test('should load skill with full content', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill).toBeDefined(); - expect(content.skill.id).toBe('review-pr'); - expect(content.skill.name).toBe('review-pr'); - expect(content.skill.description).toBeDefined(); - expect(content.skill.instructions).toBeDefined(); - expect(content.skill.instructions).toContain('github_get_pr'); + const content = result.json(); + expect(content.skills).toBeDefined(); + expect(content.skills.length).toBe(1); + const skill = content.skills[0]; + expect(skill.id).toBe('review-pr'); + expect(skill.name).toBe('review-pr'); + expect(skill.description).toBeDefined(); + expect(skill.instructions).toBeDefined(); + expect(skill.instructions).toContain('github_get_pr'); }); test('should include instructions content', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.instructions).toContain('PR Review Process'); - expect(content.skill.instructions).toContain('github_add_comment'); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.instructions).toContain('PR Review Process'); + expect(skill.instructions).toContain('github_add_comment'); }); test('should include formatted content for LLM', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.formattedContent).toBeDefined(); - expect(typeof content.formattedContent).toBe('string'); - expect(content.formattedContent.length).toBeGreaterThan(0); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.formattedContent).toBeDefined(); + expect(typeof skill.formattedContent).toBe('string'); + expect(skill.formattedContent.length).toBeGreaterThan(0); }); }); test.describe('Tool Availability', () => { test('should include available tools list', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.availableTools).toBeDefined(); - expect(Array.isArray(content.availableTools)).toBe(true); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.availableTools).toBeDefined(); + expect(Array.isArray(skill.availableTools)).toBe(true); // github_get_pr and github_add_comment should be available - expect(content.availableTools).toContain('github_get_pr'); - expect(content.availableTools).toContain('github_add_comment'); + expect(skill.availableTools).toContain('github_get_pr'); + expect(skill.availableTools).toContain('github_add_comment'); }); test('should include missing tools list', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.missingTools).toBeDefined(); - expect(Array.isArray(content.missingTools)).toBe(true); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.missingTools).toBeDefined(); + expect(Array.isArray(skill.missingTools)).toBe(true); // docker_build, docker_push, k8s_apply are not registered - expect(content.missingTools).toContain('docker_build'); - expect(content.missingTools).toContain('docker_push'); - expect(content.missingTools).toContain('k8s_apply'); + expect(skill.missingTools).toContain('docker_build'); + expect(skill.missingTools).toContain('docker_push'); + expect(skill.missingTools).toContain('k8s_apply'); }); test('should set isComplete flag based on tool availability', async ({ mcp }) => { // Skill with all tools available - const completeResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const completeResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(completeResult).toBeSuccessful(); - const completeContent = completeResult.json(); - expect(completeContent.isComplete).toBe(true); + const completeContent = completeResult.json(); + expect(completeContent.skills[0].isComplete).toBe(true); // Skill with missing tools - const incompleteResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const incompleteResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], }); expect(incompleteResult).toBeSuccessful(); - const incompleteContent = incompleteResult.json(); - expect(incompleteContent.isComplete).toBe(false); + const incompleteContent = incompleteResult.json(); + expect(incompleteContent.skills[0].isComplete).toBe(false); }); test('should include warning for missing tools', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.warning).toBeDefined(); - expect(content.warning).toContain('missing'); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.warning).toBeDefined(); + expect(skill.warning).toContain('missing'); }); test('should include tools with availability in skill object', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.tools).toBeDefined(); - expect(Array.isArray(content.skill.tools)).toBe(true); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.tools).toBeDefined(); + expect(Array.isArray(skill.tools)).toBe(true); - for (const tool of content.skill.tools) { + for (const tool of skill.tools) { expect(tool.name).toBeDefined(); expect(typeof tool.available).toBe('boolean'); } @@ -176,14 +193,15 @@ test.describe('loadSkill E2E', () => { test.describe('Tool Purposes', () => { test('should include tool purposes when defined', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - const githubGetPr = content.skill.tools.find((t) => t.name === 'github_get_pr'); + const content = result.json(); + const skill = content.skills[0]; + const githubGetPr = skill.tools.find((t) => t.name === 'github_get_pr'); expect(githubGetPr).toBeDefined(); expect(githubGetPr!.purpose).toBeDefined(); @@ -193,18 +211,19 @@ test.describe('loadSkill E2E', () => { test.describe('Parameters', () => { test('should include skill parameters', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.parameters).toBeDefined(); - expect(Array.isArray(content.skill.parameters)).toBe(true); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.parameters).toBeDefined(); + expect(Array.isArray(skill.parameters)).toBe(true); // Should have pr_url parameter - const prUrlParam = content.skill.parameters!.find((p) => p.name === 'pr_url'); + const prUrlParam = skill.parameters!.find((p) => p.name === 'pr_url'); expect(prUrlParam).toBeDefined(); expect(prUrlParam!.required).toBe(true); expect(prUrlParam!.type).toBe('string'); @@ -213,77 +232,86 @@ test.describe('loadSkill E2E', () => { test.describe('Hidden Skills', () => { test('should load hidden skills directly by ID', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'hidden-internal', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['hidden-internal'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill).toBeDefined(); - expect(content.skill.name).toBe('hidden-internal'); + const content = result.json(); + expect(content.skills).toBeDefined(); + expect(content.skills.length).toBe(1); + expect(content.skills[0].name).toBe('hidden-internal'); }); }); test.describe('Format Options', () => { test('should return full format by default', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // Full format includes formattedContent with full skill details - expect(content.formattedContent).toBeDefined(); - expect(content.skill).toBeDefined(); + expect(skill.formattedContent).toBeDefined(); + expect(skill.id).toBeDefined(); }); test('should return instructions-only format when requested', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], format: 'instructions-only', }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // Instructions-only format has simpler formattedContent - expect(content.formattedContent).toBeDefined(); + expect(skill.formattedContent).toBeDefined(); // formattedContent should be the raw instructions - expect(content.formattedContent).toContain('PR Review Process'); + expect(skill.formattedContent).toContain('PR Review Process'); }); }); test.describe('Error Handling', () => { test('should return error for non-existent skill', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'non-existent-skill-xyz-12345', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['non-existent-skill-xyz-12345'], }); - expect(result).toBeError(); + // When all skills fail to load, the tool returns empty skills array with warning + expect(result).toBeSuccessful(); + const content = result.json(); + expect(content.skills.length).toBe(0); + expect(content.summary.combinedWarnings).toBeDefined(); + expect(content.summary.combinedWarnings!.some((w) => w.includes('not found'))).toBe(true); }); }); test.describe('Skill with Simple Tool References', () => { test('should load skill with simple string tool references', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.name).toBe('notify-team'); - expect(content.availableTools).toContain('slack_notify'); - expect(content.isComplete).toBe(true); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.name).toBe('notify-team'); + expect(skill.availableTools).toContain('slack_notify'); + expect(skill.isComplete).toBe(true); }); }); test.describe('Tool Discovery', () => { - test('should list loadSkill tool', async ({ mcp }) => { + test('should list loadSkills tool', async ({ mcp }) => { const tools = await mcp.tools.list(); - expect(tools).toContainTool('loadSkill'); + expect(tools).toContainTool('loadSkills'); }); }); }); diff --git a/apps/e2e/demo-e2e-skills/e2e/multi-skill-loading.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/multi-skill-loading.e2e.test.ts index aa18517c..44c055a0 100644 --- a/apps/e2e/demo-e2e-skills/e2e/multi-skill-loading.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/multi-skill-loading.e2e.test.ts @@ -10,15 +10,13 @@ */ import { test, expect } from '@frontmcp/testing'; -interface LoadSkillResult { - skill: { - id: string; - name: string; - description: string; - instructions: string; - tools: Array<{ name: string; purpose?: string; available: boolean }>; - parameters?: Array<{ name: string; description?: string; required?: boolean; type?: string }>; - }; +interface SkillResult { + id: string; + name: string; + description: string; + instructions: string; + tools: Array<{ name: string; purpose?: string; available: boolean }>; + parameters?: Array<{ name: string; description?: string; required?: boolean; type?: string }>; availableTools: string[]; missingTools: string[]; isComplete: boolean; @@ -33,6 +31,17 @@ interface LoadSkillResult { }; } +interface LoadSkillsResult { + skills: SkillResult[]; + summary: { + totalSkills: number; + totalTools: number; + allToolsAvailable: boolean; + combinedWarnings?: string[]; + }; + nextSteps: string; +} + interface GitHubPRResult { pr: { number: number; @@ -58,144 +67,151 @@ test.describe('Multi-Skill Loading E2E', () => { test.describe('Loading Multiple Skills', () => { test('should load first skill and return skill content', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.id).toBe('review-pr'); - expect(content.availableTools).toContain('github_get_pr'); - expect(content.availableTools).toContain('github_add_comment'); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.id).toBe('review-pr'); + expect(skill.availableTools).toContain('github_get_pr'); + expect(skill.availableTools).toContain('github_add_comment'); // Session info may or may not be present depending on session context availability - if (content.session !== undefined) { - expect(content.session.activated).toBeDefined(); + if (skill.session !== undefined) { + expect(skill.session.activated).toBeDefined(); } }); test('should load second skill after first', async ({ mcp }) => { // Load first skill - const firstResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const firstResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(firstResult).toBeSuccessful(); // Load second skill - const secondResult = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const secondResult = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], activateSession: true, }); expect(secondResult).toBeSuccessful(); - const content = secondResult.json(); - expect(content.skill.id).toBe('notify-team'); - expect(content.availableTools).toContain('slack_notify'); + const content = secondResult.json(); + const skill = content.skills[0]; + expect(skill.id).toBe('notify-team'); + expect(skill.availableTools).toContain('slack_notify'); }); test('should track tool availability from both skills', async ({ mcp }) => { // Load review-pr skill - const reviewResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const reviewResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(reviewResult).toBeSuccessful(); - const reviewContent = reviewResult.json(); + const reviewContent = reviewResult.json(); // Load notify-team skill - const notifyResult = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const notifyResult = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], }); expect(notifyResult).toBeSuccessful(); - const notifyContent = notifyResult.json(); + const notifyContent = notifyResult.json(); // Review skill should have github tools - expect(reviewContent.availableTools).toContain('github_get_pr'); - expect(reviewContent.availableTools).toContain('github_add_comment'); + expect(reviewContent.skills[0].availableTools).toContain('github_get_pr'); + expect(reviewContent.skills[0].availableTools).toContain('github_add_comment'); // Notify skill should have slack tools - expect(notifyContent.availableTools).toContain('slack_notify'); + expect(notifyContent.skills[0].availableTools).toContain('slack_notify'); }); test('should load full-pr-workflow skill with all tools', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'full-pr-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['full-pr-workflow'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.id).toBe('full-pr-workflow'); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.id).toBe('full-pr-workflow'); // This skill includes tools from both github and slack - expect(content.availableTools).toContain('github_get_pr'); - expect(content.availableTools).toContain('github_add_comment'); - expect(content.availableTools).toContain('slack_notify'); - expect(content.isComplete).toBe(true); + expect(skill.availableTools).toContain('github_get_pr'); + expect(skill.availableTools).toContain('github_add_comment'); + expect(skill.availableTools).toContain('slack_notify'); + expect(skill.isComplete).toBe(true); }); }); test.describe('Session Activation', () => { test('should include session object when activateSession is true and session context exists', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // Session object may or may not be present depending on session context availability // When present, it should have an activated field - if (content.session !== undefined) { - expect(content.session.activated).toBeDefined(); + if (skill.session !== undefined) { + expect(skill.session.activated).toBeDefined(); } }); test('should not include session object when activateSession is false', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: false, }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // Session should not be present when activateSession is false - expect(content.session).toBeUndefined(); + expect(skill.session).toBeUndefined(); }); test('should respect policyMode parameter when session is activated', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'strict', }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, policyMode should be set - if (content.session?.activated) { - expect(content.session.policyMode).toBe('strict'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('strict'); } }); test('should default to permissive policyMode when session is activated', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, default policyMode should be permissive - if (content.session?.activated) { - expect(content.session.policyMode).toBe('permissive'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('permissive'); } }); }); @@ -203,8 +219,8 @@ test.describe('Multi-Skill Loading E2E', () => { test.describe('Tool Execution After Skill Loading', () => { test('should execute tool from loaded skill', async ({ mcp }) => { // Load review-pr skill - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(loadResult).toBeSuccessful(); @@ -221,14 +237,14 @@ test.describe('Multi-Skill Loading E2E', () => { test('should execute tools from different loaded skills', async ({ mcp }) => { // Load review-pr skill - const reviewLoad = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const reviewLoad = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(reviewLoad).toBeSuccessful(); // Load notify-team skill - const notifyLoad = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const notifyLoad = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], }); expect(notifyLoad).toBeSuccessful(); @@ -252,32 +268,34 @@ test.describe('Multi-Skill Loading E2E', () => { test.describe('Skill Incompleteness', () => { test('should identify incomplete skill with missing tools', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill.id).toBe('deploy-app'); - expect(content.isComplete).toBe(false); - expect(content.missingTools).toContain('docker_build'); - expect(content.missingTools).toContain('docker_push'); - expect(content.missingTools).toContain('k8s_apply'); - expect(content.warning).toBeDefined(); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.id).toBe('deploy-app'); + expect(skill.isComplete).toBe(false); + expect(skill.missingTools).toContain('docker_build'); + expect(skill.missingTools).toContain('docker_push'); + expect(skill.missingTools).toContain('k8s_apply'); + expect(skill.warning).toBeDefined(); }); test('should still include available tools for incomplete skill', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // slack_notify is available even though other tools are missing - expect(content.availableTools).toContain('slack_notify'); + expect(skill.availableTools).toContain('slack_notify'); }); }); @@ -303,59 +321,64 @@ test.describe('Multi-Skill Loading E2E', () => { test.describe('Format Options', () => { test('should return full format by default', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.formattedContent).toBeDefined(); - expect(content.skill).toBeDefined(); - expect(content.skill.instructions).toContain('PR Review Process'); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.formattedContent).toBeDefined(); + expect(skill.id).toBeDefined(); + expect(skill.instructions).toContain('PR Review Process'); }); test('should return instructions-only format when requested', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], format: 'instructions-only', }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.formattedContent).toBeDefined(); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.formattedContent).toBeDefined(); // Instructions-only format should be the raw instructions - expect(content.formattedContent).toContain('PR Review Process'); + expect(skill.formattedContent).toContain('PR Review Process'); }); }); test.describe('Error Handling', () => { - test('should return error for non-existent skill', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'non-existent-skill-xyz', + test('should return warning for non-existent skill', async ({ mcp }) => { + const result = await mcp.tools.call('loadSkills', { + skillIds: ['non-existent-skill-xyz'], }); - expect(result).toBeError(); + expect(result).toBeSuccessful(); + const content = result.json(); + expect(content.skills.length).toBe(0); + expect(content.summary.combinedWarnings).toBeDefined(); }); test('should handle loading same skill twice', async ({ mcp }) => { // Load skill first time - const firstLoad = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const firstLoad = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(firstLoad).toBeSuccessful(); // Load same skill again - should succeed - const secondLoad = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const secondLoad = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(secondLoad).toBeSuccessful(); - const content = secondLoad.json(); - expect(content.skill.id).toBe('review-pr'); + const content = secondLoad.json(); + expect(content.skills[0].id).toBe('review-pr'); }); }); }); diff --git a/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts index 110aec1c..41421225 100644 --- a/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts @@ -3,7 +3,7 @@ * * Tests plugin-level skill functionality: * - Plugin skill discovery via searchSkills - * - Plugin skill loading via loadSkill + * - Plugin skill loading via loadSkills * - Plugin tools execution * - Mixed app and plugin skills * - Hidden plugin skill handling @@ -24,15 +24,13 @@ interface SkillParameter { type?: string; } -interface LoadSkillResult { - skill: { - id: string; - name: string; - description: string; - instructions: string; - tools: SkillTool[]; - parameters?: SkillParameter[]; - }; +interface SkillResult { + id: string; + name: string; + description: string; + instructions: string; + tools: SkillTool[]; + parameters?: SkillParameter[]; availableTools: string[]; missingTools: string[]; isComplete: boolean; @@ -46,6 +44,17 @@ interface LoadSkillResult { }; } +interface LoadSkillsResult { + skills: SkillResult[]; + summary: { + totalSkills: number; + totalTools: number; + allToolsAvailable: boolean; + combinedWarnings?: string[]; + }; + nextSteps: string; +} + interface SearchSkillsResult { skills: Array<{ id: string; @@ -169,57 +178,63 @@ test.describe('Plugin Skills E2E', () => { test.describe('Loading', () => { test('should load plugin skill with full content', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], format: 'full', }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill).toBeDefined(); - expect(content.skill.id).toBe('deploy-workflow'); - expect(content.skill.name).toBe('deploy-workflow'); - expect(content.skill.instructions).toContain('Deployment Workflow'); - expect(content.skill.tools.length).toBe(2); + const content = result.json(); + expect(content.skills).toBeDefined(); + expect(content.skills.length).toBe(1); + const skill = content.skills[0]; + expect(skill.id).toBe('deploy-workflow'); + expect(skill.name).toBe('deploy-workflow'); + expect(skill.instructions).toContain('Deployment Workflow'); + expect(skill.tools.length).toBe(2); }); test('should show plugin tools as available', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.availableTools).toContain('deploy_application'); - expect(content.availableTools).toContain('rollback_deployment'); - expect(content.isComplete).toBe(true); + const content = result.json(); + const skill = content.skills[0]; + expect(skill.availableTools).toContain('deploy_application'); + expect(skill.availableTools).toContain('rollback_deployment'); + expect(skill.isComplete).toBe(true); }); test('should load hidden plugin skill by direct ID', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'plugin-internal-skill', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['plugin-internal-skill'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill).toBeDefined(); - expect(content.skill.id).toBe('plugin-internal-skill'); - expect(content.skill.name).toBe('plugin-internal-skill'); + const content = result.json(); + expect(content.skills).toBeDefined(); + expect(content.skills.length).toBe(1); + const skill = content.skills[0]; + expect(skill.id).toBe('plugin-internal-skill'); + expect(skill.name).toBe('plugin-internal-skill'); }); test('should include tool purposes in plugin skill', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], }); expect(result).toBeSuccessful(); - const content = result.json(); - const deployTool = content.skill.tools.find((t) => t.name === 'deploy_application'); - const rollbackTool = content.skill.tools.find((t) => t.name === 'rollback_deployment'); + const content = result.json(); + const skill = content.skills[0]; + const deployTool = skill.tools.find((t) => t.name === 'deploy_application'); + const rollbackTool = skill.tools.find((t) => t.name === 'rollback_deployment'); expect(deployTool).toBeDefined(); expect(deployTool!.purpose).toContain('Deploy the application'); @@ -231,8 +246,8 @@ test.describe('Plugin Skills E2E', () => { test.describe('Tool Execution', () => { test('should execute plugin tools after loading plugin skill', async ({ mcp }) => { // Load the skill first - await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], activateSession: true, }); @@ -296,47 +311,47 @@ test.describe('Plugin Skills E2E', () => { test('should load plugin skill after loading app skill', async ({ mcp }) => { // Load app skill - const appResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const appResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(appResult).toBeSuccessful(); // Load plugin skill - const pluginResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const pluginResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], activateSession: true, }); expect(pluginResult).toBeSuccessful(); - const pluginContent = pluginResult.json(); - expect(pluginContent.skill.id).toBe('deploy-workflow'); + const pluginContent = pluginResult.json(); + expect(pluginContent.skills[0].id).toBe('deploy-workflow'); }); test('should have distinct tool sets between app and plugin skills', async ({ mcp }) => { // Load app skill - const appResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const appResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(appResult).toBeSuccessful(); - const appContent = appResult.json(); - expect(appContent.availableTools).toContain('github_get_pr'); - expect(appContent.availableTools).not.toContain('deploy_application'); + const appContent = appResult.json(); + expect(appContent.skills[0].availableTools).toContain('github_get_pr'); + expect(appContent.skills[0].availableTools).not.toContain('deploy_application'); // Load plugin skill - const pluginResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const pluginResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], }); expect(pluginResult).toBeSuccessful(); - const pluginContent = pluginResult.json(); - expect(pluginContent.availableTools).toContain('deploy_application'); - expect(pluginContent.availableTools).not.toContain('github_get_pr'); + const pluginContent = pluginResult.json(); + expect(pluginContent.skills[0].availableTools).toContain('deploy_application'); + expect(pluginContent.skills[0].availableTools).not.toContain('github_get_pr'); }); test('should use app tools and plugin tools in sequence', async ({ mcp }) => { @@ -362,21 +377,22 @@ test.describe('Plugin Skills E2E', () => { test.describe('Authorization with Plugin Skills', () => { test('should enforce tool allowlist in strict mode with plugin skill', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], activateSession: true, policyMode: 'strict', }); expect(loadResult).toBeSuccessful(); - const loadContent = loadResult.json(); + const loadContent = loadResult.json(); + const skill = loadContent.skills[0]; // Session should show strict policy mode - if (loadContent.session?.activated) { - expect(loadContent.session.policyMode).toBe('strict'); - expect(loadContent.session.allowedTools).toContain('deploy_application'); - expect(loadContent.session.allowedTools).toContain('rollback_deployment'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('strict'); + expect(skill.session.allowedTools).toContain('deploy_application'); + expect(skill.session.allowedTools).toContain('rollback_deployment'); } // Plugin tool should work @@ -388,36 +404,38 @@ test.describe('Plugin Skills E2E', () => { }); test('should verify session shows plugin skill tools as allowed', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, verify tools - if (content.session?.activated) { - expect(content.session.allowedTools).toBeDefined(); - expect(content.session.allowedTools).toContain('deploy_application'); - expect(content.session.allowedTools).toContain('rollback_deployment'); + if (skill.session?.activated) { + expect(skill.session.allowedTools).toBeDefined(); + expect(skill.session.allowedTools).toContain('deploy_application'); + expect(skill.session.allowedTools).toContain('rollback_deployment'); } }); test('should handle approval mode with plugin skill', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], activateSession: true, policyMode: 'approval', }); expect(loadResult).toBeSuccessful(); - const loadContent = loadResult.json(); + const loadContent = loadResult.json(); + const skill = loadContent.skills[0]; - if (loadContent.session?.activated) { - expect(loadContent.session.policyMode).toBe('approval'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('approval'); } // Tool in allowlist should work @@ -444,7 +462,7 @@ test.describe('Plugin Skills E2E', () => { expect(tools).toContainTool('rollback_deployment'); // Built-in skill tools - expect(tools).toContainTool('loadSkill'); + expect(tools).toContainTool('loadSkills'); expect(tools).toContainTool('searchSkills'); }); }); @@ -462,15 +480,15 @@ test.describe('Plugin Skills E2E', () => { expect(searchContent.skills.some((s) => s.id === 'deploy-workflow')).toBe(true); // 2. Load the skill - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-workflow', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-workflow'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const loadContent = loadResult.json(); - expect(loadContent.skill.instructions).toContain('Deployment Workflow'); + const loadContent = loadResult.json(); + expect(loadContent.skills[0].instructions).toContain('Deployment Workflow'); // 3. Deploy to staging first const stagingResult = await mcp.tools.call('deploy_application', { diff --git a/apps/e2e/demo-e2e-skills/e2e/skill-session.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/skill-session.e2e.test.ts index 0000300f..3a0e5401 100644 --- a/apps/e2e/demo-e2e-skills/e2e/skill-session.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/skill-session.e2e.test.ts @@ -12,20 +12,35 @@ */ import { test, expect } from '@frontmcp/testing'; -interface LoadSkillResult { - skill: { - id: string; - name: string; - description: string; - instructions: string; - tools: Array<{ name: string; purpose?: string; available: boolean }>; - parameters?: Array<{ name: string; description?: string; required?: boolean; type?: string }>; - }; +interface SkillResult { + id: string; + name: string; + description: string; + instructions: string; + tools: Array<{ name: string; purpose?: string; available: boolean }>; + parameters?: Array<{ name: string; description?: string; required?: boolean; type?: string }>; availableTools: string[]; missingTools: string[]; isComplete: boolean; warning?: string; formattedContent: string; + session?: { + activated: boolean; + sessionId?: string; + policyMode?: 'strict' | 'approval' | 'permissive'; + allowedTools?: string[]; + }; +} + +interface LoadSkillsResult { + skills: SkillResult[]; + summary: { + totalSkills: number; + totalTools: number; + allToolsAvailable: boolean; + combinedWarnings?: string[]; + }; + nextSteps: string; } interface SearchSkillsResult { @@ -64,32 +79,34 @@ test.describe('Skill Session E2E', () => { test.describe('Tool Availability in Skills', () => { test('should correctly identify available tools when loading skill', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // These tools are registered in the app - expect(content.availableTools).toContain('github_get_pr'); - expect(content.availableTools).toContain('github_add_comment'); + expect(skill.availableTools).toContain('github_get_pr'); + expect(skill.availableTools).toContain('github_add_comment'); }); test('should correctly identify missing tools when loading skill', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // These tools are NOT registered in the app - expect(content.missingTools).toContain('docker_build'); - expect(content.missingTools).toContain('docker_push'); - expect(content.missingTools).toContain('k8s_apply'); + expect(skill.missingTools).toContain('docker_build'); + expect(skill.missingTools).toContain('docker_push'); + expect(skill.missingTools).toContain('k8s_apply'); // slack_notify IS registered - expect(content.availableTools).toContain('slack_notify'); + expect(skill.availableTools).toContain('slack_notify'); }); }); @@ -129,42 +146,43 @@ test.describe('Skill Session E2E', () => { test.describe('Multi-Skill Tool Coverage', () => { test('should show combined tool availability across skills', async ({ mcp }) => { // Load skill that uses github tools - const reviewResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const reviewResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(reviewResult).toBeSuccessful(); - const reviewContent = reviewResult.json(); + const reviewContent = reviewResult.json(); // Load skill that uses slack tools - const notifyResult = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const notifyResult = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], }); expect(notifyResult).toBeSuccessful(); - const notifyContent = notifyResult.json(); + const notifyContent = notifyResult.json(); // Review skill should have github tools - expect(reviewContent.availableTools).toContain('github_get_pr'); - expect(reviewContent.availableTools).toContain('github_add_comment'); + expect(reviewContent.skills[0].availableTools).toContain('github_get_pr'); + expect(reviewContent.skills[0].availableTools).toContain('github_add_comment'); // Notify skill should have slack tools - expect(notifyContent.availableTools).toContain('slack_notify'); + expect(notifyContent.skills[0].availableTools).toContain('slack_notify'); }); test('should identify full workflow skill with multiple tool types', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'full-pr-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['full-pr-workflow'], }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // This skill uses all three available tools - expect(content.availableTools).toContain('github_get_pr'); - expect(content.availableTools).toContain('github_add_comment'); - expect(content.availableTools).toContain('slack_notify'); - expect(content.isComplete).toBe(true); + expect(skill.availableTools).toContain('github_get_pr'); + expect(skill.availableTools).toContain('github_add_comment'); + expect(skill.availableTools).toContain('slack_notify'); + expect(skill.isComplete).toBe(true); }); }); @@ -181,19 +199,20 @@ test.describe('Skill Session E2E', () => { expect(searchContent.skills.length).toBeGreaterThan(0); // Step 2: Load the first matching skill - const skillId = searchContent.skills[0].id; - const loadResult = await mcp.tools.call('loadSkill', { - skillId, + const skillIdToLoad = searchContent.skills[0].id; + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: [skillIdToLoad], }); expect(loadResult).toBeSuccessful(); - const loadContent = loadResult.json(); - expect(loadContent.skill.instructions).toBeDefined(); - expect(loadContent.formattedContent).toBeDefined(); + const loadContent = loadResult.json(); + const skill = loadContent.skills[0]; + expect(skill.instructions).toBeDefined(); + expect(skill.formattedContent).toBeDefined(); // Step 3: Use a tool mentioned in the skill - if (loadContent.availableTools.includes('github_get_pr')) { + if (skill.availableTools.includes('github_get_pr')) { const toolResult = await mcp.tools.call('github_get_pr', { prNumber: 42, }); @@ -279,8 +298,8 @@ test.describe('Skill Session E2E', () => { expect(searchResult).toBeSuccessful(); // 2. Load the skill - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(loadResult).toBeSuccessful(); @@ -303,14 +322,14 @@ test.describe('Skill Session E2E', () => { test('should support notification workflow', async ({ mcp }) => { // 1. Load the notify-team skill - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], }); expect(loadResult).toBeSuccessful(); - const loadContent = loadResult.json(); - expect(loadContent.skill.instructions).toContain('slack_notify'); + const loadContent = loadResult.json(); + expect(loadContent.skills[0].instructions).toContain('slack_notify'); // 2. Send notification const notifyResult = await mcp.tools.call('slack_notify', { @@ -324,180 +343,144 @@ test.describe('Skill Session E2E', () => { test.describe('Session Activation', () => { test('should return session info when activateSession is true and session context exists', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json< - LoadSkillResult & { - session?: { - activated: boolean; - sessionId?: string; - policyMode?: string; - allowedTools?: string[]; - }; - } - >(); + const content = result.json(); + const skill = content.skills[0]; // Session object may or may not be present depending on session context availability // When present, it should have an activated field - if (content.session !== undefined) { - expect(typeof content.session.activated).toBe('boolean'); + if (skill.session !== undefined) { + expect(typeof skill.session.activated).toBe('boolean'); } }); test('should not return session info when activateSession is false', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: false, }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // Session should not be present when activateSession is false - expect(content.session).toBeUndefined(); + expect(skill.session).toBeUndefined(); }); test('should not return session info when activateSession is not specified', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); + const content = result.json(); + const skill = content.skills[0]; // Session should not be present when activateSession defaults to false - expect(content.session).toBeUndefined(); + expect(skill.session).toBeUndefined(); }); }); test.describe('Policy Mode Override', () => { test('should set strict policyMode when specified', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'strict', }); expect(result).toBeSuccessful(); - const content = result.json< - LoadSkillResult & { - session?: { - activated: boolean; - policyMode?: string; - }; - } - >(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, verify policy mode - if (content.session?.activated) { - expect(content.session.policyMode).toBe('strict'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('strict'); } }); test('should set approval policyMode when specified', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'approval', }); expect(result).toBeSuccessful(); - const content = result.json< - LoadSkillResult & { - session?: { - activated: boolean; - policyMode?: string; - }; - } - >(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, verify policy mode - if (content.session?.activated) { - expect(content.session.policyMode).toBe('approval'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('approval'); } }); test('should default to permissive policyMode', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json< - LoadSkillResult & { - session?: { - activated: boolean; - policyMode?: string; - }; - } - >(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, default policy mode should be permissive - if (content.session?.activated) { - expect(content.session.policyMode).toBe('permissive'); + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('permissive'); } }); }); test.describe('Session Allowed Tools', () => { test('should include allowedTools in session when activated', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json< - LoadSkillResult & { - session?: { - activated: boolean; - allowedTools?: string[]; - }; - } - >(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, allowedTools should match availableTools - if (content.session?.activated) { - expect(content.session.allowedTools).toBeDefined(); - expect(content.session.allowedTools).toContain('github_get_pr'); - expect(content.session.allowedTools).toContain('github_add_comment'); + if (skill.session?.activated) { + expect(skill.session.allowedTools).toBeDefined(); + expect(skill.session.allowedTools).toContain('github_get_pr'); + expect(skill.session.allowedTools).toContain('github_add_comment'); } }); test('should include full-pr-workflow tools in session', async ({ mcp }) => { - const result = await mcp.tools.call('loadSkill', { - skillId: 'full-pr-workflow', + const result = await mcp.tools.call('loadSkills', { + skillIds: ['full-pr-workflow'], activateSession: true, }); expect(result).toBeSuccessful(); - const content = result.json< - LoadSkillResult & { - session?: { - activated: boolean; - allowedTools?: string[]; - }; - } - >(); + const content = result.json(); + const skill = content.skills[0]; // If session was activated, verify all tools from full workflow - if (content.session?.activated) { - expect(content.session.allowedTools).toContain('github_get_pr'); - expect(content.session.allowedTools).toContain('github_add_comment'); - expect(content.session.allowedTools).toContain('slack_notify'); + if (skill.session?.activated) { + expect(skill.session.allowedTools).toContain('github_get_pr'); + expect(skill.session.allowedTools).toContain('github_add_comment'); + expect(skill.session.allowedTools).toContain('slack_notify'); } }); }); diff --git a/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts index 52e83785..7a0b7edf 100644 --- a/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts @@ -12,20 +12,29 @@ */ import { test, expect } from '@frontmcp/testing'; -interface LoadSkillResult { - skill: { - id: string; - name: string; - description: string; - instructions: string; - tools: Array<{ name: string; available: boolean }>; - }; +interface SkillResult { + id: string; + name: string; + description: string; + instructions: string; + tools: Array<{ name: string; available: boolean }>; availableTools: string[]; missingTools: string[]; isComplete: boolean; formattedContent: string; } +interface LoadSkillsResult { + skills: SkillResult[]; + summary: { + totalSkills: number; + totalTools: number; + allToolsAvailable: boolean; + combinedWarnings?: string[]; + }; + nextSteps: string; +} + test.describe('MCP Skills-Only Mode E2E', () => { test.use({ server: 'apps/e2e/demo-e2e-skills/src/main.ts', @@ -44,11 +53,11 @@ test.describe('MCP Skills-Only Mode E2E', () => { expect(tools).toContainTool('slack_notify'); }); - test('should list searchSkills and loadSkill tools', async ({ mcp }) => { + test('should list searchSkills and loadSkills tools', async ({ mcp }) => { const tools = await mcp.tools.list(); expect(tools).toContainTool('searchSkills'); - expect(tools).toContainTool('loadSkill'); + expect(tools).toContainTool('loadSkills'); }); }); @@ -97,7 +106,7 @@ test.describe('MCP Skills-Only Mode E2E', () => { } }); - test('should still allow loadSkill in skills-only mode', async ({ server }) => { + test('should still allow loadSkills in skills-only mode', async ({ server }) => { const builder = server.createClientBuilder(); const client = await builder .withTransport('streamable-http') @@ -105,16 +114,17 @@ test.describe('MCP Skills-Only Mode E2E', () => { .buildAndConnect(); try { - const result = await client.tools.call('loadSkill', { - skillId: 'review-pr', + const result = await client.tools.call('loadSkills', { + skillIds: ['review-pr'], }); expect(result).toBeSuccessful(); - const content = result.json(); - expect(content.skill).toBeDefined(); - expect(content.skill.id).toBe('review-pr'); - expect(content.formattedContent).toBeDefined(); + const content = result.json(); + expect(content.skills).toBeDefined(); + expect(content.skills.length).toBe(1); + expect(content.skills[0].id).toBe('review-pr'); + expect(content.skills[0].formattedContent).toBeDefined(); } finally { await client.disconnect(); } diff --git a/apps/e2e/demo-e2e-skills/e2e/tool-authorization.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/tool-authorization.e2e.test.ts index 4639fea2..92fc0ff9 100644 --- a/apps/e2e/demo-e2e-skills/e2e/tool-authorization.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/tool-authorization.e2e.test.ts @@ -13,14 +13,12 @@ */ import { test, expect } from '@frontmcp/testing'; -interface LoadSkillResult { - skill: { - id: string; - name: string; - description: string; - instructions: string; - tools: Array<{ name: string; purpose?: string; available: boolean }>; - }; +interface SkillResult { + id: string; + name: string; + description: string; + instructions: string; + tools: Array<{ name: string; purpose?: string; available: boolean }>; availableTools: string[]; missingTools: string[]; isComplete: boolean; @@ -34,6 +32,17 @@ interface LoadSkillResult { }; } +interface LoadSkillsResult { + skills: SkillResult[]; + summary: { + totalSkills: number; + totalTools: number; + allToolsAvailable: boolean; + combinedWarnings?: string[]; + }; + nextSteps: string; +} + interface AdminActionResult { result: string; success: boolean; @@ -99,8 +108,8 @@ test.describe('Tool Authorization E2E', () => { test.describe('Policy Mode - Permissive (Default)', () => { test('should allow tools in skill allowlist', async ({ mcp }) => { // Load skill with permissive mode (default) - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(loadResult).toBeSuccessful(); @@ -117,8 +126,8 @@ test.describe('Tool Authorization E2E', () => { test('should allow tools not in skill allowlist in permissive mode', async ({ mcp }) => { // Load skill with default permissive mode - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'permissive', }); @@ -137,15 +146,16 @@ test.describe('Tool Authorization E2E', () => { }); test('should verify session shows permissive policyMode', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); - if (content.session?.activated) { - expect(content.session.policyMode).toBe('permissive'); + const content = loadResult.json(); + const skill = content.skills[0]; + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('permissive'); } }); }); @@ -153,8 +163,8 @@ test.describe('Tool Authorization E2E', () => { test.describe('Policy Mode - Strict', () => { test('should allow tools in skill allowlist with strict mode', async ({ mcp }) => { // Load skill with strict mode - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'strict', }); @@ -171,32 +181,34 @@ test.describe('Tool Authorization E2E', () => { }); test('should verify session shows strict policyMode', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'strict', }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); - if (content.session?.activated) { - expect(content.session.policyMode).toBe('strict'); + const content = loadResult.json(); + const skill = content.skills[0]; + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('strict'); } }); test('should include allowedTools in session when activated', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'strict', }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); - if (content.session?.activated) { - expect(content.session.allowedTools).toBeDefined(); - expect(content.session.allowedTools).toContain('github_get_pr'); - expect(content.session.allowedTools).toContain('github_add_comment'); + const content = loadResult.json(); + const skill = content.skills[0]; + if (skill.session?.activated) { + expect(skill.session.allowedTools).toBeDefined(); + expect(skill.session.allowedTools).toContain('github_get_pr'); + expect(skill.session.allowedTools).toContain('github_add_comment'); } }); }); @@ -204,8 +216,8 @@ test.describe('Tool Authorization E2E', () => { test.describe('Policy Mode - Approval', () => { test('should allow tools in skill allowlist with approval mode', async ({ mcp }) => { // Load skill with approval mode - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'approval', }); @@ -220,156 +232,164 @@ test.describe('Tool Authorization E2E', () => { }); test('should verify session shows approval policyMode', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, policyMode: 'approval', }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); - if (content.session?.activated) { - expect(content.session.policyMode).toBe('approval'); + const content = loadResult.json(); + const skill = content.skills[0]; + if (skill.session?.activated) { + expect(skill.session.policyMode).toBe('approval'); } }); }); test.describe('Tool Allowlist Tracking', () => { test('should track review-pr skill tools', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // review-pr skill should have these tools - expect(content.skill.tools.find((t) => t.name === 'github_get_pr')).toBeDefined(); - expect(content.skill.tools.find((t) => t.name === 'github_add_comment')).toBeDefined(); + expect(skill.tools.find((t) => t.name === 'github_get_pr')).toBeDefined(); + expect(skill.tools.find((t) => t.name === 'github_add_comment')).toBeDefined(); // admin_action should NOT be in the skill's tools - expect(content.skill.tools.find((t) => t.name === 'admin_action')).toBeUndefined(); + expect(skill.tools.find((t) => t.name === 'admin_action')).toBeUndefined(); }); test('should track notify-team skill tools', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // notify-team skill should have slack_notify - expect(content.skill.tools.find((t) => t.name === 'slack_notify')).toBeDefined(); - expect(content.availableTools).toContain('slack_notify'); + expect(skill.tools.find((t) => t.name === 'slack_notify')).toBeDefined(); + expect(skill.availableTools).toContain('slack_notify'); // admin_action should NOT be in the skill's tools - expect(content.skill.tools.find((t) => t.name === 'admin_action')).toBeUndefined(); + expect(skill.tools.find((t) => t.name === 'admin_action')).toBeUndefined(); }); test('should track full-pr-workflow skill tools', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'full-pr-workflow', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['full-pr-workflow'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // full-pr-workflow has all three main tools - expect(content.skill.tools.find((t) => t.name === 'github_get_pr')).toBeDefined(); - expect(content.skill.tools.find((t) => t.name === 'github_add_comment')).toBeDefined(); - expect(content.skill.tools.find((t) => t.name === 'slack_notify')).toBeDefined(); + expect(skill.tools.find((t) => t.name === 'github_get_pr')).toBeDefined(); + expect(skill.tools.find((t) => t.name === 'github_add_comment')).toBeDefined(); + expect(skill.tools.find((t) => t.name === 'slack_notify')).toBeDefined(); // admin_action should NOT be in the skill's tools - expect(content.skill.tools.find((t) => t.name === 'admin_action')).toBeUndefined(); + expect(skill.tools.find((t) => t.name === 'admin_action')).toBeUndefined(); }); }); test.describe('Tool Availability', () => { test('should mark available tools correctly', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // Tools that are registered should be marked as available - const githubGetPr = content.skill.tools.find((t) => t.name === 'github_get_pr'); - const githubAddComment = content.skill.tools.find((t) => t.name === 'github_add_comment'); + const githubGetPr = skill.tools.find((t) => t.name === 'github_get_pr'); + const githubAddComment = skill.tools.find((t) => t.name === 'github_add_comment'); expect(githubGetPr?.available).toBe(true); expect(githubAddComment?.available).toBe(true); }); test('should mark missing tools correctly', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'deploy-app', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['deploy-app'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // Tools that are NOT registered should be marked as unavailable - const dockerBuild = content.skill.tools.find((t) => t.name === 'docker_build'); - const dockerPush = content.skill.tools.find((t) => t.name === 'docker_push'); - const k8sApply = content.skill.tools.find((t) => t.name === 'k8s_apply'); + const dockerBuild = skill.tools.find((t) => t.name === 'docker_build'); + const dockerPush = skill.tools.find((t) => t.name === 'docker_push'); + const k8sApply = skill.tools.find((t) => t.name === 'k8s_apply'); expect(dockerBuild?.available).toBe(false); expect(dockerPush?.available).toBe(false); expect(k8sApply?.available).toBe(false); // But slack_notify should be available - const slackNotify = content.skill.tools.find((t) => t.name === 'slack_notify'); + const slackNotify = skill.tools.find((t) => t.name === 'slack_notify'); expect(slackNotify?.available).toBe(true); }); }); test.describe('Session State Verification', () => { test('should indicate activation status when session context exists', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // Session object may or may not be present depending on session context availability // When present, it should have an activated field - if (content.session !== undefined) { - expect(content.session.activated).toBeDefined(); + if (skill.session !== undefined) { + expect(skill.session.activated).toBeDefined(); } }); test('should not return session object when not requested', async ({ mcp }) => { - const loadResult = await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + const loadResult = await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], // activateSession not set (defaults to false) }); expect(loadResult).toBeSuccessful(); - const content = loadResult.json(); + const content = loadResult.json(); + const skill = content.skills[0]; // When activateSession is false/not set, session should not be in response - expect(content.session).toBeUndefined(); + expect(skill.session).toBeUndefined(); }); }); test.describe('Cross-Skill Tool Access', () => { test('should access tools across multiple loaded skills', async ({ mcp }) => { // Load review-pr skill - await mcp.tools.call('loadSkill', { - skillId: 'review-pr', + await mcp.tools.call('loadSkills', { + skillIds: ['review-pr'], activateSession: true, }); // Load notify-team skill - await mcp.tools.call('loadSkill', { - skillId: 'notify-team', + await mcp.tools.call('loadSkills', { + skillIds: ['notify-team'], activateSession: true, }); @@ -394,7 +414,7 @@ test.describe('Tool Authorization E2E', () => { expect(tools).toContainTool('github_add_comment'); expect(tools).toContainTool('slack_notify'); expect(tools).toContainTool('admin_action'); - expect(tools).toContainTool('loadSkill'); + expect(tools).toContainTool('loadSkills'); expect(tools).toContainTool('searchSkills'); }); }); diff --git a/libs/sdk/src/skill/flows/load-skill.flow.ts b/libs/sdk/src/skill/flows/load-skill.flow.ts index fb338d8f..5c94f795 100644 --- a/libs/sdk/src/skill/flows/load-skill.flow.ts +++ b/libs/sdk/src/skill/flows/load-skill.flow.ts @@ -10,12 +10,12 @@ import type { SkillSessionManager } from '../session/skill-session.manager'; import type { SkillPolicyMode, SkillActivationResult } from '../session/skill-session.types'; import type { Scope } from '../../scope'; -// Input schema matching MCP request format - now supports multiple skill IDs +// Input schema matching MCP request format - supports multiple skill IDs const inputSchema = z.object({ request: z.object({ method: z.literal('skills/load'), params: z.object({ - skillIds: z.array(z.string().min(1)).min(1).max(5).describe('Array of skill IDs or names to load (1-5 skills)'), + skillIds: z.array(z.string().min(1)).min(1).max(5).describe('Array of skill IDs to load (1-5 skills)'), format: z .enum(['full', 'instructions-only']) .default('full') @@ -166,6 +166,7 @@ export default class LoadSkillFlow extends FlowBase { } const { skillIds, format, activateSession, policyMode } = params; + this.state.set({ skillIds, format, activateSession, policyMode, warnings: [] }); this.logger.verbose('parseInput:done'); } diff --git a/libs/sdk/src/skill/tools/load-skills.tool.ts b/libs/sdk/src/skill/tools/load-skills.tool.ts index 170cb3f5..f553e118 100644 --- a/libs/sdk/src/skill/tools/load-skills.tool.ts +++ b/libs/sdk/src/skill/tools/load-skills.tool.ts @@ -10,14 +10,7 @@ import type { ToolRegistryInterface } from '../../common'; * Input schema for loadSkills tool. */ const inputSchema = { - skillIds: z - .array(z.string().min(1)) - .min(1) - .max(5) - .describe( - 'Array of skill IDs or names to load. Load one skill when you know exactly what you need, ' + - 'or load multiple related skills (up to 5) to combine their workflows.', - ), + skillIds: z.array(z.string().min(1)).min(1).max(5).describe('Array of skill IDs to load (1-5 skills)'), format: z .enum(['full', 'instructions-only']) .default('full') diff --git a/libs/sdk/src/transport/flows/handle.sse.flow.ts b/libs/sdk/src/transport/flows/handle.sse.flow.ts index 944c7b84..f09baeab 100644 --- a/libs/sdk/src/transport/flows/handle.sse.flow.ts +++ b/libs/sdk/src/transport/flows/handle.sse.flow.ts @@ -6,7 +6,6 @@ import { FlowPlan, FlowBase, FlowHooksOf, - sessionIdSchema, httpRespond, ServerRequestTokens, Authorization, From bf6eabd2ea05929832e8b86b86a5de9a3f875ae3 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 26 Jan 2026 02:08:25 +0200 Subject: [PATCH 3/5] feat: Update type definitions and improve error handling in skill management --- libs/sdk/src/common/entries/tool.entry.ts | 30 +++++++++---- .../types/options/skills-http/interfaces.ts | 4 +- .../types/options/skills-http/schema.ts | 16 ++++++- libs/sdk/src/skill/auth/skill-http-auth.ts | 42 +++++++++++++++++-- .../skill/cache/skill-http-cache.factory.ts | 8 ++-- .../skill/cache/skill-http-cache.holder.ts | 13 ++++-- .../src/skill/flows/http/skills-api.flow.ts | 39 ++++++++++------- libs/sdk/src/skill/flows/load-skill.flow.ts | 7 +++- .../sdk/src/skill/flows/search-skills.flow.ts | 12 ++++-- libs/sdk/src/skill/skill-http.utils.ts | 5 ++- .../sdk/src/skill/tools/search-skills.tool.ts | 10 +++-- libs/sdk/src/tool/tool.instance.ts | 4 +- libs/sdk/src/tool/tool.utils.ts | 2 +- libs/testing/src/client/mcp-test-client.ts | 9 ++-- 14 files changed, 151 insertions(+), 50 deletions(-) diff --git a/libs/sdk/src/common/entries/tool.entry.ts b/libs/sdk/src/common/entries/tool.entry.ts index 296ca316..12d7e8fd 100644 --- a/libs/sdk/src/common/entries/tool.entry.ts +++ b/libs/sdk/src/common/entries/tool.entry.ts @@ -52,11 +52,11 @@ export abstract class ToolEntry< inputSchema: InSchema; // This is whatever JSON-schema-ish thing you store for input; keeping type loose - rawInputSchema: any; + rawInputSchema: unknown; // This is your *metadata* outputSchema (literals / zod / raw shapes / arrays) outputSchema?: OutSchema; // Raw JSON Schema for output (for tool/list to expose) - rawOutputSchema?: any; + rawOutputSchema?: unknown; /** * Accessor used by tools/list to expose the tool's declared outputSchema. @@ -71,7 +71,7 @@ export abstract class ToolEntry< * Accessor used by tools/list to expose the tool's output schema as JSON Schema. * Returns the raw JSON Schema representation if available. */ - getRawOutputSchema(): any | undefined { + getRawOutputSchema(): unknown | undefined { return this.rawOutputSchema; } @@ -87,16 +87,32 @@ export abstract class ToolEntry< getInputJsonSchema(): Record | null { // Prefer rawInputSchema if already in JSON Schema format if (this.rawInputSchema) { - return this.rawInputSchema; + return this.rawInputSchema as Record; } // Convert Zod schema shape to JSON Schema if (this.inputSchema && Object.keys(this.inputSchema).length > 0) { try { - // Dynamic import to avoid circular dependencies - const { z, toJSONSchema } = require('zod'); + // Try Zod v4 import path first + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { z } = require('zod'); + let toJSONSchema: (schema: unknown) => Record; + try { + // Zod v4: toJSONSchema is in 'zod/v4/core' + // eslint-disable-next-line @typescript-eslint/no-require-imports + const zodV4 = require('zod/v4/core'); + toJSONSchema = zodV4.toJSONSchema; + } catch { + // Zod v3: toJSONSchema may be available as zod-to-json-schema or not at all + // In this case, return a basic schema + return { type: 'object', properties: {} }; + } return toJSONSchema(z.object(this.inputSchema)); - } catch { + } catch (error) { + // Log the error for debugging purposes + if (process.env['DEBUG'] || process.env['NODE_ENV'] === 'development') { + console.warn('[ToolEntry] Failed to convert Zod schema to JSON Schema:', error); + } return { type: 'object', properties: {} }; } } diff --git a/libs/sdk/src/common/types/options/skills-http/interfaces.ts b/libs/sdk/src/common/types/options/skills-http/interfaces.ts index 103720b5..b7d5953b 100644 --- a/libs/sdk/src/common/types/options/skills-http/interfaces.ts +++ b/libs/sdk/src/common/types/options/skills-http/interfaces.ts @@ -312,10 +312,12 @@ export interface SkillsConfigCacheOptions { /** * Redis configuration for distributed caching. * If not provided, falls back to in-memory cache. + * + * Note: 'redis' provider uses ioredis under the hood. */ redis?: { /** Redis provider type */ - provider: 'redis' | 'ioredis' | 'vercel-kv' | '@vercel/kv'; + provider: 'redis' | 'vercel-kv' | '@vercel/kv'; /** Redis host */ host?: string; /** Redis port */ diff --git a/libs/sdk/src/common/types/options/skills-http/schema.ts b/libs/sdk/src/common/types/options/skills-http/schema.ts index 976d1208..a0acc96a 100644 --- a/libs/sdk/src/common/types/options/skills-http/schema.ts +++ b/libs/sdk/src/common/types/options/skills-http/schema.ts @@ -33,12 +33,14 @@ export const skillsConfigJwtOptionsSchema = z.object({ /** * Cache configuration schema. + * + * Supports 'redis' (uses ioredis under the hood) and 'vercel-kv' providers. */ export const skillsConfigCacheOptionsSchema = z.object({ enabled: z.boolean().optional().default(false), redis: z .object({ - provider: z.enum(['redis', 'ioredis', 'vercel-kv', '@vercel/kv']), + provider: z.enum(['redis', 'vercel-kv', '@vercel/kv']), host: z.string().optional(), port: z.number().int().positive().optional(), password: z.string().optional(), @@ -143,10 +145,20 @@ export function normalizeSkillsConfigOptions(options: SkillsConfigOptionsInput | normalizedApi: NormalizedEndpointConfig; } { const parsed = skillsConfigOptionsSchema.parse(options ?? {}); - const prefix = parsed.prefix ?? ''; const auth = parsed.auth ?? 'inherit'; const apiKeys = parsed.apiKeys; + // Normalize prefix: ensure leading slash, remove trailing slash + let prefix = parsed.prefix ?? ''; + if (prefix) { + if (!prefix.startsWith('/')) { + prefix = '/' + prefix; + } + if (prefix.endsWith('/')) { + prefix = prefix.slice(0, -1); + } + } + return { ...parsed, normalizedLlmTxt: normalizeEndpointConfig(parsed.llmTxt, `${prefix}/llm.txt`, auth, apiKeys), diff --git a/libs/sdk/src/skill/auth/skill-http-auth.ts b/libs/sdk/src/skill/auth/skill-http-auth.ts index bdfe5935..ec07254f 100644 --- a/libs/sdk/src/skill/auth/skill-http-auth.ts +++ b/libs/sdk/src/skill/auth/skill-http-auth.ts @@ -13,6 +13,7 @@ import type { FrontMcpLogger } from '../../common'; import type { SkillsConfigOptions } from '../../common/types/options/skills-http'; +import { timingSafeEqual } from '@frontmcp/utils'; /** * Request context for auth validation. @@ -110,6 +111,8 @@ export class SkillHttpAuthValidator { * Accepts API key in: * - X-API-Key header * - Authorization header as `ApiKey ` + * + * Uses timing-safe comparison to prevent timing attacks. */ private validateApiKey(ctx: SkillHttpAuthContext): SkillHttpAuthResult { const apiKeys = this.skillsConfig.apiKeys ?? []; @@ -127,15 +130,15 @@ export class SkillHttpAuthValidator { const authHeader = this.getHeader(ctx.headers, 'authorization'); const apiKeyHeader = this.getHeader(ctx.headers, 'x-api-key'); - // Check X-API-Key header first - if (apiKeyHeader && apiKeys.includes(apiKeyHeader)) { + // Check X-API-Key header first using timing-safe comparison + if (apiKeyHeader && this.timingSafeIncludes(apiKeys, apiKeyHeader)) { return { authorized: true }; } // Check Authorization: ApiKey format if (authHeader?.startsWith('ApiKey ')) { const key = authHeader.slice(7); - if (apiKeys.includes(key)) { + if (this.timingSafeIncludes(apiKeys, key)) { return { authorized: true }; } } @@ -147,6 +150,33 @@ export class SkillHttpAuthValidator { }; } + /** + * Check if any key in the list matches the candidate using timing-safe comparison. + * This prevents timing attacks that could reveal information about valid API keys. + */ + private timingSafeIncludes(keys: string[], candidate: string): boolean { + const encoder = new TextEncoder(); + const candidateBytes = encoder.encode(candidate); + + // We must check all keys to ensure constant-time behavior + // Even if we find a match, continue checking to prevent timing leaks + let found = false; + for (const key of keys) { + const keyBytes = encoder.encode(key); + // Only compare if lengths match (length difference is not timing-sensitive) + if (keyBytes.length === candidateBytes.length) { + try { + if (timingSafeEqual(keyBytes, candidateBytes)) { + found = true; + } + } catch { + // Should not happen since we checked lengths, but handle gracefully + } + } + } + return found; + } + /** * Validate Bearer token (JWT) authentication. * @@ -206,10 +236,14 @@ export class SkillHttpAuthValidator { /** * Get a header value from the headers object. + * Performs case-insensitive header name lookup per HTTP spec. * Handles both string and string[] values. */ private getHeader(headers: Record, name: string): string | undefined { - const value = headers[name] ?? headers[name.toLowerCase()]; + const lowerName = name.toLowerCase(); + // Find the header key case-insensitively + const key = Object.keys(headers).find((k) => k.toLowerCase() === lowerName); + const value = key ? headers[key] : undefined; if (Array.isArray(value)) { return value[0]; } diff --git a/libs/sdk/src/skill/cache/skill-http-cache.factory.ts b/libs/sdk/src/skill/cache/skill-http-cache.factory.ts index bb0a9b53..8bdfe4ab 100644 --- a/libs/sdk/src/skill/cache/skill-http-cache.factory.ts +++ b/libs/sdk/src/skill/cache/skill-http-cache.factory.ts @@ -11,10 +11,12 @@ import { SkillHttpCache, MemorySkillHttpCache, RedisSkillHttpCache } from './ski /** * Redis configuration options for the cache. + * + * Supports 'redis' (uses ioredis under the hood) and 'vercel-kv' providers. */ export interface SkillHttpCacheRedisOptions { /** Redis provider type */ - provider: 'redis' | 'ioredis' | 'vercel-kv' | '@vercel/kv'; + provider: 'redis' | 'vercel-kv' | '@vercel/kv'; /** Redis host */ host?: string; /** Redis port */ @@ -69,7 +71,7 @@ export interface SkillHttpCacheResult { function hasRedisProvider(redis: SkillHttpCacheRedisOptions | undefined): boolean { if (!redis?.provider) return false; const provider = redis.provider; - return provider === 'redis' || provider === 'ioredis' || provider === 'vercel-kv' || provider === '@vercel/kv'; + return provider === 'redis' || provider === 'vercel-kv' || provider === '@vercel/kv'; } /** @@ -146,7 +148,7 @@ async function createRedisCache( }); } - // Default to ioredis - use require for CommonJS compatibility + // Use ioredis for 'redis' provider - use require for CommonJS compatibility // eslint-disable-next-line @typescript-eslint/no-require-imports const Redis = require('ioredis'); const client = new Redis({ diff --git a/libs/sdk/src/skill/cache/skill-http-cache.holder.ts b/libs/sdk/src/skill/cache/skill-http-cache.holder.ts index 8782c77f..b76d8aac 100644 --- a/libs/sdk/src/skill/cache/skill-http-cache.holder.ts +++ b/libs/sdk/src/skill/cache/skill-http-cache.holder.ts @@ -68,11 +68,13 @@ export async function getSkillHttpCache(scope: ScopeEntry): Promise { } } - private async handleGetSkill(skillId: string, skillRegistry: any, toolRegistry: any) { + private async handleGetSkill( + skillId: string, + skillRegistry: SkillRegistryInterface, + toolRegistry: ToolRegistryInterface | null, + ) { const loadResult = await skillRegistry.loadSkill(skillId); if (!loadResult) { @@ -233,10 +240,10 @@ export default class SkillsApiFlow extends FlowBase { const { skill, availableTools, missingTools, isComplete, warning } = loadResult; - // Check visibility + // Check visibility - look up by skill ID only for accurate matching const skillEntry = skillRegistry .getSkills(true) - .find((s: any) => s.name === skill.name || s.metadata.id === skill.id); + .find((s) => s.metadata.id === skill.id || (s.metadata.id === undefined && s.name === skill.id)); if (skillEntry) { const visibility = skillEntry.metadata.visibility ?? 'both'; if (visibility === 'mcp') { @@ -250,8 +257,10 @@ export default class SkillsApiFlow extends FlowBase { } } - // Generate formatted content with tool schemas - const formattedContent = formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry); + // Generate formatted content with tool schemas (use fallback if toolRegistry is null) + const formattedContent = toolRegistry + ? formatSkillForLLMWithSchemas(skill, availableTools, missingTools, toolRegistry) + : formatSkillForLLM(skill, availableTools, missingTools); this.respond( httpRespond.json({ @@ -280,7 +289,7 @@ export default class SkillsApiFlow extends FlowBase { private async handleSearchSkills( query: string, options: { tags?: string[]; tools?: string[]; limit?: number }, - skillRegistry: any, + skillRegistry: SkillRegistryInterface, ) { const results = await skillRegistry.search(query, { topK: options.limit ?? 10, @@ -289,20 +298,20 @@ export default class SkillsApiFlow extends FlowBase { }); // Filter by HTTP visibility - const filteredResults = results.filter((r: any) => { + const filteredResults = results.filter((r) => { const visibility = r.metadata.visibility ?? 'both'; return visibility !== 'mcp'; }); this.respond( httpRespond.json({ - skills: filteredResults.map((r: any) => ({ + skills: filteredResults.map((r) => ({ id: r.metadata.id ?? r.metadata.name, name: r.metadata.name, description: r.metadata.description, score: r.score, tags: r.metadata.tags ?? [], - tools: (r.metadata.tools ?? []).map((t: any) => (typeof t === 'string' ? t : t.name)), + tools: (r.metadata.tools ?? []).map((t) => (typeof t === 'string' ? t : (t as { name: string }).name)), priority: r.metadata.priority ?? 0, visibility: r.metadata.visibility ?? 'both', })), @@ -313,7 +322,7 @@ export default class SkillsApiFlow extends FlowBase { private async handleListSkills( options: { tags?: string[]; tools?: string[]; limit?: number; offset?: number }, - skillRegistry: any, + skillRegistry: SkillRegistryInterface, ) { // Get skills visible via HTTP const allSkills = skillRegistry.getSkills({ includeHidden: false, visibility: 'http' }); @@ -321,7 +330,7 @@ export default class SkillsApiFlow extends FlowBase { // Apply tag filter if specified let filteredSkills = allSkills; if (options.tags && options.tags.length > 0) { - filteredSkills = filteredSkills.filter((s: any) => { + filteredSkills = filteredSkills.filter((s) => { const skillTags = s.metadata.tags ?? []; return options.tags!.some((t) => skillTags.includes(t)); }); @@ -329,8 +338,10 @@ export default class SkillsApiFlow extends FlowBase { // Apply tool filter if specified if (options.tools && options.tools.length > 0) { - filteredSkills = filteredSkills.filter((s: any) => { - const skillTools = (s.metadata.tools ?? []).map((t: any) => (typeof t === 'string' ? t : t.name)); + filteredSkills = filteredSkills.filter((s) => { + const skillTools = (s.metadata.tools ?? []).map((t) => + typeof t === 'string' ? t : (t as { name: string }).name, + ); return options.tools!.some((t) => skillTools.includes(t)); }); } @@ -344,7 +355,7 @@ export default class SkillsApiFlow extends FlowBase { this.respond( httpRespond.json({ - skills: paginatedSkills.map((s: any) => skillToApiResponse(s)), + skills: paginatedSkills.map((s) => skillToApiResponse(s)), total, hasMore, offset, diff --git a/libs/sdk/src/skill/flows/load-skill.flow.ts b/libs/sdk/src/skill/flows/load-skill.flow.ts index 5c94f795..b55a9fd6 100644 --- a/libs/sdk/src/skill/flows/load-skill.flow.ts +++ b/libs/sdk/src/skill/flows/load-skill.flow.ts @@ -278,6 +278,9 @@ export default class LoadSkillFlow extends FlowBase { let totalTools = 0; let allToolsAvailable = true; + // Pre-index tool entries for O(1) lookup instead of O(n) per tool + const toolEntryByName = toolRegistry ? new Map(toolRegistry.getTools(true).map((te) => [te.name, te])) : null; + for (const { loadResult, activationResult } of loadResults) { const { skill, availableTools, missingTools, isComplete, warning } = loadResult; @@ -301,8 +304,8 @@ export default class LoadSkillFlow extends FlowBase { }; // Include schemas for available tools - if (isAvailable && toolRegistry) { - const toolEntry = toolRegistry.getTools(true).find((te) => te.name === t.name); + if (isAvailable && toolEntryByName) { + const toolEntry = toolEntryByName.get(t.name); if (toolEntry) { if (toolEntry.rawInputSchema) { result.inputSchema = toolEntry.rawInputSchema; diff --git a/libs/sdk/src/skill/flows/search-skills.flow.ts b/libs/sdk/src/skill/flows/search-skills.flow.ts index 4da7f1b7..1c791440 100644 --- a/libs/sdk/src/skill/flows/search-skills.flow.ts +++ b/libs/sdk/src/skill/flows/search-skills.flow.ts @@ -157,6 +157,9 @@ export default class SearchSkillsFlow extends FlowBase { this.logger.verbose('finalize:start'); const { results, options } = this.state.required; + // Store pre-filtered count for hasMore calculation + const preFilteredCount = (results as SkillSearchResult[]).length; + // Filter by MCP visibility (only 'mcp' or 'both' should be visible via MCP tools) const mcpVisibleResults = (results as SkillSearchResult[]).filter((result) => { const visibility = result.metadata.visibility ?? 'both'; @@ -192,13 +195,14 @@ export default class SearchSkillsFlow extends FlowBase { })); // Pagination info: - // - total: number of results returned (search already filtered by query/tags/tools) - // - hasMore: true if we hit the limit (indicating more results may exist) - // Note: We can't know the exact total of matching skills without a full scan, + // - total: number of MCP-visible results returned + // - hasMore: true if pre-filtered results hit the limit (more results may exist) + // Note: We use preFilteredCount for hasMore because visibility filtering is post-search. + // We can't know the exact total of matching skills without a full scan, // so we report the actual returned count and indicate if limit was reached. const limit = options.topK ?? 10; const total = skills.length; - const hasMore = skills.length >= limit; + const hasMore = preFilteredCount >= limit; const output: Output = { skills, diff --git a/libs/sdk/src/skill/skill-http.utils.ts b/libs/sdk/src/skill/skill-http.utils.ts index 5327b2e2..597525b7 100644 --- a/libs/sdk/src/skill/skill-http.utils.ts +++ b/libs/sdk/src/skill/skill-http.utils.ts @@ -140,6 +140,9 @@ export function formatSkillForLLMWithSchemas( parts.push(''); } + // Pre-index tool entries for O(1) lookup instead of O(n) per tool + const toolEntryByName = new Map(toolRegistry.getTools(true).map((te) => [te.name, te])); + // Tools section WITH FULL SCHEMAS if (skill.tools.length > 0) { parts.push('## Tools'); @@ -156,7 +159,7 @@ export function formatSkillForLLMWithSchemas( // Include full schema if tool is available if (isAvailable) { - const toolEntry = toolRegistry.getTools(true).find((t) => t.name === tool.name); + const toolEntry = toolEntryByName.get(tool.name); if (toolEntry) { const inputSchema = getToolInputSchema(toolEntry); const outputSchema = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema; diff --git a/libs/sdk/src/skill/tools/search-skills.tool.ts b/libs/sdk/src/skill/tools/search-skills.tool.ts index 28d3e75a..d8bbb212 100644 --- a/libs/sdk/src/skill/tools/search-skills.tool.ts +++ b/libs/sdk/src/skill/tools/search-skills.tool.ts @@ -115,6 +115,9 @@ export class SearchSkillsTool extends ToolContext { const visibility = result.metadata.visibility ?? 'both'; @@ -155,10 +158,11 @@ export class SearchSkillsTool extends ToolContext= input.limit; + const hasMore = preFilteredCount >= input.limit; // Generate guidance based on results const guidance = generateSearchGuidance( diff --git a/libs/sdk/src/tool/tool.instance.ts b/libs/sdk/src/tool/tool.instance.ts index 64e7581c..6eb2a241 100644 --- a/libs/sdk/src/tool/tool.instance.ts +++ b/libs/sdk/src/tool/tool.instance.ts @@ -121,9 +121,9 @@ export class ToolInstance< // Check if elicitation is enabled in scope (default: false) const elicitationEnabled = this.scope.metadata.elicitation?.enabled === true; - if (elicitationEnabled && baseSchema !== undefined) { + if (elicitationEnabled && baseSchema !== undefined && baseSchema !== null) { // Extend schema to include elicitation fallback response type - return extendOutputSchemaForElicitation(baseSchema); + return extendOutputSchemaForElicitation(baseSchema as Record); } return baseSchema; diff --git a/libs/sdk/src/tool/tool.utils.ts b/libs/sdk/src/tool/tool.utils.ts index f62c9533..6e8dbcba 100644 --- a/libs/sdk/src/tool/tool.utils.ts +++ b/libs/sdk/src/tool/tool.utils.ts @@ -456,7 +456,7 @@ export function buildAgentToolDefinitions(tools: ToolEntry[]): AgentToolDefiniti let parameters: Record; if (tool.rawInputSchema) { // Already converted to JSON Schema - parameters = tool.rawInputSchema; + parameters = tool.rawInputSchema as Record; } else if (tool.inputSchema && Object.keys(tool.inputSchema).length > 0) { // tool.inputSchema is a ZodRawShape (extracted .shape from ZodObject in ToolInstance constructor) // Convert to JSON Schema using the same approach as tools-list.flow.ts diff --git a/libs/testing/src/client/mcp-test-client.ts b/libs/testing/src/client/mcp-test-client.ts index e891ff13..617411e1 100644 --- a/libs/testing/src/client/mcp-test-client.ts +++ b/libs/testing/src/client/mcp-test-client.ts @@ -871,11 +871,14 @@ export class McpTestClient { // ═══════════════════════════════════════════════════════════════════ private createTransport(): McpTransport { - // Build URL with query params if provided + // Build URL with query params if provided using URL API for proper handling let baseUrl = this.config.baseUrl; if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) { - const params = new URLSearchParams(this.config.queryParams); - baseUrl = `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${params.toString()}`; + const url = new URL(baseUrl); + Object.entries(this.config.queryParams).forEach(([key, value]) => { + url.searchParams.set(key, String(value)); + }); + baseUrl = url.toString(); } switch (this.config.transport) { From 820e2d37ea4a2e15879369b00d8e2eaf84a8dcda Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 26 Jan 2026 03:27:53 +0200 Subject: [PATCH 4/5] feat: Improve null safety in skill loading tests and enhance query parameter parsing --- .../e2e/load-skill.e2e.test.ts | 12 ++-- .../e2e/plugin-skills.e2e.test.ts | 6 +- libs/sdk/package.json | 8 ++- libs/sdk/src/common/entries/tool.entry.ts | 10 ++- .../src/skill/flows/http/skills-api.flow.ts | 61 ++++++++++++++----- libs/sdk/src/skill/flows/load-skill.flow.ts | 10 +-- libs/sdk/src/tool/tool.utils.ts | 4 -- 7 files changed, 76 insertions(+), 35 deletions(-) diff --git a/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts index ad128102..9a6092ad 100644 --- a/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts @@ -204,8 +204,8 @@ test.describe('loadSkills E2E', () => { const githubGetPr = skill.tools.find((t) => t.name === 'github_get_pr'); expect(githubGetPr).toBeDefined(); - expect(githubGetPr!.purpose).toBeDefined(); - expect(githubGetPr!.purpose).toContain('Fetch PR details'); + expect(githubGetPr?.purpose).toBeDefined(); + expect(githubGetPr?.purpose).toContain('Fetch PR details'); }); }); @@ -223,10 +223,10 @@ test.describe('loadSkills E2E', () => { expect(Array.isArray(skill.parameters)).toBe(true); // Should have pr_url parameter - const prUrlParam = skill.parameters!.find((p) => p.name === 'pr_url'); + const prUrlParam = skill.parameters?.find((p) => p.name === 'pr_url'); expect(prUrlParam).toBeDefined(); - expect(prUrlParam!.required).toBe(true); - expect(prUrlParam!.type).toBe('string'); + expect(prUrlParam?.required).toBe(true); + expect(prUrlParam?.type).toBe('string'); }); }); @@ -288,7 +288,7 @@ test.describe('loadSkills E2E', () => { const content = result.json(); expect(content.skills.length).toBe(0); expect(content.summary.combinedWarnings).toBeDefined(); - expect(content.summary.combinedWarnings!.some((w) => w.includes('not found'))).toBe(true); + expect(content.summary.combinedWarnings?.some((w) => w.includes('not found'))).toBe(true); }); }); diff --git a/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts b/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts index 41421225..9cad46ff 100644 --- a/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts +++ b/apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts @@ -172,7 +172,7 @@ test.describe('Plugin Skills E2E', () => { const deploySkill = content.skills.find((s) => s.id === 'deploy-workflow'); expect(deploySkill).toBeDefined(); - expect(deploySkill!.source).toBe('local'); + expect(deploySkill?.source).toBe('local'); }); }); @@ -237,9 +237,9 @@ test.describe('Plugin Skills E2E', () => { const rollbackTool = skill.tools.find((t) => t.name === 'rollback_deployment'); expect(deployTool).toBeDefined(); - expect(deployTool!.purpose).toContain('Deploy the application'); + expect(deployTool?.purpose).toContain('Deploy the application'); expect(rollbackTool).toBeDefined(); - expect(rollbackTool!.purpose).toContain('Rollback if needed'); + expect(rollbackTool?.purpose).toContain('Rollback if needed'); }); }); diff --git a/libs/sdk/package.json b/libs/sdk/package.json index 44239ad5..9bd2eb45 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -64,7 +64,13 @@ "cors": "^2.8.5", "raw-body": "^3.0.0", "content-type": "^1.0.5", - "vectoriadb": "^2.0.2" + "vectoriadb": "^2.0.2", + "@vercel/kv": "^3.0.0" + }, + "peerDependenciesMeta": { + "@vercel/kv": { + "optional": true + } }, "dependencies": { "@frontmcp/utils": "0.7.2", diff --git a/libs/sdk/src/common/entries/tool.entry.ts b/libs/sdk/src/common/entries/tool.entry.ts index 12d7e8fd..954b0f51 100644 --- a/libs/sdk/src/common/entries/tool.entry.ts +++ b/libs/sdk/src/common/entries/tool.entry.ts @@ -87,7 +87,15 @@ export abstract class ToolEntry< getInputJsonSchema(): Record | null { // Prefer rawInputSchema if already in JSON Schema format if (this.rawInputSchema) { - return this.rawInputSchema as Record; + // Validate that rawInputSchema is actually an object before casting + if ( + typeof this.rawInputSchema === 'object' && + this.rawInputSchema !== null && + !Array.isArray(this.rawInputSchema) + ) { + return this.rawInputSchema as Record; + } + // rawInputSchema exists but isn't a valid object - fall through to conversion } // Convert Zod schema shape to JSON Schema diff --git a/libs/sdk/src/skill/flows/http/skills-api.flow.ts b/libs/sdk/src/skill/flows/http/skills-api.flow.ts index f1be129f..a4235be6 100644 --- a/libs/sdk/src/skill/flows/http/skills-api.flow.ts +++ b/libs/sdk/src/skill/flows/http/skills-api.flow.ts @@ -168,18 +168,39 @@ export default class SkillsApiFlow extends FlowBase { skillId = path.slice(apiPath.length + 1); } - // Parse query parameters - const query = request.query?.['query'] as string | undefined; - const tagsParam = request.query?.['tags'] as string | string[] | undefined; - const toolsParam = request.query?.['tools'] as string | string[] | undefined; - const limitParam = request.query?.['limit'] as string | undefined; - const offsetParam = request.query?.['offset'] as string | undefined; - - // Normalize tags and tools arrays - const tags = tagsParam ? (Array.isArray(tagsParam) ? tagsParam : tagsParam.split(',')) : undefined; - const tools = toolsParam ? (Array.isArray(toolsParam) ? toolsParam : toolsParam.split(',')) : undefined; - const limit = limitParam ? parseInt(limitParam, 10) : undefined; - const offset = offsetParam ? parseInt(offsetParam, 10) : undefined; + // Parse query parameters - handle arrays (take first element) + const queryRaw = request.query?.['query']; + const query = Array.isArray(queryRaw) ? queryRaw[0] : (queryRaw as string | undefined); + + // Normalize tags and tools arrays - handle both arrays and comma-separated strings + const tagsParam = request.query?.['tags']; + const tags = tagsParam + ? Array.isArray(tagsParam) + ? tagsParam.map((t) => String(t).trim()).filter(Boolean) + : String(tagsParam) + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : undefined; + + const toolsParam = request.query?.['tools']; + const tools = toolsParam + ? Array.isArray(toolsParam) + ? toolsParam.map((t) => String(t).trim()).filter(Boolean) + : String(toolsParam) + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : undefined; + + // Parse limit/offset with validation - only accept valid numeric strings + const limitRaw = request.query?.['limit']; + const limitStr = Array.isArray(limitRaw) ? limitRaw[0] : limitRaw; + const limit = limitStr && /^\d+$/.test(String(limitStr)) ? parseInt(String(limitStr), 10) : undefined; + + const offsetRaw = request.query?.['offset']; + const offsetStr = Array.isArray(offsetRaw) ? offsetRaw[0] : offsetRaw; + const offset = offsetStr && /^\d+$/.test(String(offsetStr)) ? parseInt(String(offsetStr), 10) : undefined; // Determine action let action: 'list' | 'search' | 'get'; @@ -213,10 +234,20 @@ export default class SkillsApiFlow extends FlowBase { switch (action) { case 'get': - await this.handleGetSkill(skillId!, skillRegistry, toolRegistry); + if (!skillId) { + this.respond( + httpRespond.json({ error: 'Bad Request', message: 'Missing skillId parameter' }, { status: 400 }), + ); + return; + } + await this.handleGetSkill(skillId, skillRegistry, toolRegistry); break; case 'search': - await this.handleSearchSkills(query!, { tags, tools, limit }, skillRegistry); + if (!query) { + this.respond(httpRespond.json({ error: 'Bad Request', message: 'Missing query parameter' }, { status: 400 })); + return; + } + await this.handleSearchSkills(query, { tags, tools, limit }, skillRegistry); break; case 'list': await this.handleListSkills({ tags, tools, limit, offset }, skillRegistry); @@ -269,7 +300,7 @@ export default class SkillsApiFlow extends FlowBase { name: skill.name, description: skill.description, instructions: skill.instructions, - tools: skill.tools.map((t: any) => ({ + tools: skill.tools.map((t) => ({ name: t.name, purpose: t.purpose, available: availableTools.includes(t.name), diff --git a/libs/sdk/src/skill/flows/load-skill.flow.ts b/libs/sdk/src/skill/flows/load-skill.flow.ts index b55a9fd6..2901376d 100644 --- a/libs/sdk/src/skill/flows/load-skill.flow.ts +++ b/libs/sdk/src/skill/flows/load-skill.flow.ts @@ -229,16 +229,16 @@ export default class LoadSkillFlow extends FlowBase { return; } + // Override policy mode if specified (session-level setting, apply before activating skills) + if (policyMode) { + sessionManager.setPolicyMode(policyMode as SkillPolicyMode); + } + // Activate each skill for (const item of loadResults) { const { skill } = item.loadResult; const activationResult = sessionManager.activateSkill(skill.id, skill, item.loadResult); - // Override policy mode if specified - if (policyMode) { - sessionManager.setPolicyMode(policyMode as SkillPolicyMode); - } - item.activationResult = activationResult; this.logger.info(`activateSessions: activated skill "${skill.id}"`, { policyMode: activationResult.session.policyMode, diff --git a/libs/sdk/src/tool/tool.utils.ts b/libs/sdk/src/tool/tool.utils.ts index 6e8dbcba..f287eb7b 100644 --- a/libs/sdk/src/tool/tool.utils.ts +++ b/libs/sdk/src/tool/tool.utils.ts @@ -25,14 +25,10 @@ import { z, ZodBigInt, ZodBoolean, ZodDate, ZodNumber, ZodString } from 'zod'; import { toJSONSchema } from 'zod/v4'; // Import utilities from @frontmcp/utils -import type { NameCase } from '@frontmcp/utils'; -import { splitWords, toCase, sepFor, shortHash, ensureMaxLen } from '@frontmcp/utils'; // MCP-specific naming utilities -import { normalizeSegment, normalizeProviderId, normalizeOwnerPath } from '../utils/naming.utils'; // Lineage utilities -import { ownerKeyOf, qualifiedNameOf } from '../utils/lineage.utils'; export function collectToolMetadata(cls: ToolType): ToolMetadata { const extended = getMetadata(extendedToolMetadata, cls); From 6f956f18ddb4ded83280871e39fa316ee45038ed Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 26 Jan 2026 03:39:09 +0200 Subject: [PATCH 5/5] feat: Enhance input schema handling by utilizing getInputJsonSchema for improved flexibility --- libs/sdk/src/skill/flows/load-skill.flow.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/sdk/src/skill/flows/load-skill.flow.ts b/libs/sdk/src/skill/flows/load-skill.flow.ts index 2901376d..8fa9f63d 100644 --- a/libs/sdk/src/skill/flows/load-skill.flow.ts +++ b/libs/sdk/src/skill/flows/load-skill.flow.ts @@ -307,8 +307,10 @@ export default class LoadSkillFlow extends FlowBase { if (isAvailable && toolEntryByName) { const toolEntry = toolEntryByName.get(t.name); if (toolEntry) { - if (toolEntry.rawInputSchema) { - result.inputSchema = toolEntry.rawInputSchema; + // Use getInputJsonSchema() to handle both raw JSON schemas and Zod-defined schemas + const inputSchema = toolEntry.getInputJsonSchema?.() ?? toolEntry.rawInputSchema; + if (inputSchema) { + result.inputSchema = inputSchema; } const rawOutput = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema; if (rawOutput) {