Skip to content

Conversation

@frontegg-david
Copy link
Contributor

@frontegg-david frontegg-david commented Jan 25, 2026

Summary by CodeRabbit

  • New Features

    • Skills visibility (mcp/http/both), skills-only mode flag, per-session skillsOnlyMode, HTTP skills endpoints (/llm.txt, /llm_full.txt, JSON API) with auth and optional caching, multi-skill loading with per-skill sessions and aggregated summaries, and tool/schema-aware formatting and guidance.
  • Tests

    • Extensive unit and E2E tests for endpoints, visibility, skills-only mode, caching, multi-skill flows, and tooling.
  • Documentation

    • Added Skills Feature Organization guidance (duplicated block noted).

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

Adds a Skills HTTP subsystem: new configurable endpoints (/llm.txt, /llm_full.txt, /skills), visibility controls, auth modes (public/api-key/bearer), per-scope caching (memory/Redis), skills-only session support, multi-skill load APIs/tools/flows, registry/provider updates, formatter/auth/cache utilities, and many tests.

Changes

Cohort / File(s) Summary
Docs & E2E tests
CLAUDE.md, apps/e2e/demo-e2e-skills/e2e/*.e2e.test.ts
Adds docs (duplicated block in CLAUDE.md) and extensive E2E suites covering /llm.txt, /llm_full.txt, /skills, skills-only mode, auth, visibility, multi-skill loading, and caching.
Demo app & skills
apps/e2e/demo-e2e-skills/src/main.ts, apps/e2e/demo-e2e-skills/src/apps/skills/*, .../skills/*.skill.ts
Enables skillsConfig, sets auth/public, adds McpOnlySkill and HttpOnlySkill and wires them into the demo app.
Metadata & tokens
libs/sdk/src/common/metadata/front-mcp.metadata.ts, libs/sdk/src/common/metadata/skill.metadata.ts, libs/sdk/src/common/tokens/*
Adds skillsConfig to FrontMcp metadata, introduces visibility/SkillVisibility on skills and tokens for visibility metadata.
Skills HTTP config & schemas
libs/sdk/src/common/types/options/skills-http/*, libs/sdk/src/common/types/options/index.ts
New public types, Zod schemas and normalization utilities for skills HTTP config (endpoints, auth modes, apiKeys, cache, prefix, mcpTools).
Session & skills-only mode
libs/sdk/src/common/types/auth/session.types.ts, libs/sdk/src/auth/session/utils/session-id.utils.ts, libs/sdk/src/skill/skill-mode.utils.ts, libs/sdk/src/transport/flows/*
Adds skillsOnlyMode session flag, detection helpers, passes mode from transports into session creation; tests added.
Tool entry & tool utils
libs/sdk/src/common/entries/tool.entry.ts, libs/sdk/src/tool/tool.utils.ts, libs/sdk/src/tool/tool.instance.ts
Tightens schema typing (any→unknown), adds getInputJsonSchema() with Zod conversion fallbacks, minor casts/import cleanup and small guard fix.
Skill HTTP auth
libs/sdk/src/skill/auth/skill-http-auth.ts, libs/sdk/src/skill/auth/index.ts
New SkillHttpAuthValidator supporting public/api-key/bearer (JWKS) with lazy jose import and factory helper; re-export added.
HTTP caching subsystem
libs/sdk/src/skill/cache/*
New SkillHttpCache interface, Memory/Redis implementations, factory (createSkillHttpCache), per-scope cache holder (get/invalidate/dispose), and exports; Redis/vercel-kv wiring with fallback.
HTTP formatting & API utils
libs/sdk/src/skill/skill-http.utils.ts, libs/sdk/src/skill/__tests__/*
New compact/full/with-schema formatters, skillToApiResponse, filterSkillsByVisibility, CompactSkillSummary and unit tests.
Skill scope registration & init
libs/sdk/src/skill/skill-scope.helper.ts, libs/sdk/src/scope/scope.instance.ts
Centralized registerSkillCapabilities() to register MCP tools and conditional HTTP flows; scope init refactored to call it; NotificationService init made async.
HTTP flows
libs/sdk/src/skill/flows/http/*
New flows: LlmTxtFlow (/llm.txt), LlmFullTxtFlow (/llm_full.txt), SkillsApiFlow (/skills) — include enablement/auth/cache/visibility logic and global typing augmentations.
Load & search flows / tools
libs/sdk/src/skill/flows/load-skill.flow.ts, libs/sdk/src/skill/flows/search-skills.flow.ts, libs/sdk/src/skill/tools/*
Load flow now supports multiple skillIds and aggregated results/nextSteps; search flow applies MCP visibility filtering; LoadSkillTool removed and replaced by LoadSkillsTool (with deprecated alias); search tool adds guidance and canExecute.
Skill instance, registry & provider
libs/sdk/src/skill/skill.instance.ts, libs/sdk/src/skill/skill.registry.ts, libs/sdk/src/skill/providers/memory-skill.provider.ts
SkillInstance and memory provider now track/expose visibility; SkillRegistry.getSkills accepts new GetSkillsOptions (includeHidden/visibility) and applies visibility filtering.
Skill helpers & public exports
libs/sdk/src/skill/skill.utils.ts, libs/sdk/src/skill/index.ts
Adds generateNextSteps and generateSearchGuidance helpers (duplication observed), expands public exports for formatting, auth, caching, mode utils and registration helpers.
Tool listing & testing client
libs/sdk/src/tool/flows/tools-list.flow.ts, libs/testing/src/client/*
ToolsListFlow early-exits for skills-only sessions; testing client adds queryParams support and builder.withQueryParams() to append mode flags to transport URLs.
Packaging
libs/sdk/package.json
Adds optional peer dependency @vercel/kv to support Vercel KV provider.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Server as Server (Flow)
  participant Auth as SkillHttpAuthValidator
  participant Cache as SkillHttpCache
  participant Registry as SkillRegistry
  participant Tools as ToolRegistry

  Client->>Server: GET /skills?query=...
  Server->>Auth: validate(headers) (if auth enabled)
  Auth-->>Server: authorized / error
  alt authorized
    Server->>Cache: getSkillsList(hash)
    Cache-->>Server: cached list / null
    alt cache miss
      Server->>Registry: search/get visible skills (visibility=http)
      Registry-->>Server: SkillEntry[]
      loop per skill
        Server->>Registry: loadSkill(skillId)
        Registry-->>Server: SkillContent
        Server->>Tools: get availability + schemas
        Tools-->>Server: tool availability & schemas
      end
      Server->>Cache: setSkillsList(hash, result)
    end
    Server-->>Client: 200 application/json (skills list)
  else auth error
    Auth-->>Client: 401/403
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through code and stitched each thread,

Endpoints bloom where skills are spread,
Auth and cache keep the garden neat,
Multi-skill dances — a clever feat,
I nibble bugs and celebrate with a carrot-fed tread!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: implementing skills HTTP configuration and visibility management features across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 96.77% which is sufficient. The required threshold is 65.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
libs/sdk/src/skill/flows/search-skills.flow.ts (1)

199-201: Minor pagination edge case with post-filter visibility.

The hasMore calculation may be inaccurate when visibility filtering removes HTTP-only skills. If the search returns limit results but some are filtered out, hasMore could incorrectly report false even when more MCP-visible skills exist.

Consider passing visibility: 'mcp' to the registry search if supported, or document this as a known limitation.

libs/sdk/src/skill/index.ts (1)

37-183: Add documentation for new public API exports under docs/draft/docs.

The following newly exported utilities lack documentation in docs/draft/docs:

  • HTTP caching system: SkillHttpCache, MemorySkillHttpCache, RedisSkillHttpCache, createSkillHttpCache, getSkillHttpCache, invalidateScopeCache, invalidateSkillInCache, disposeAllCaches
  • HTTP authentication: SkillHttpAuthValidator, createSkillHttpAuthValidator
  • HTTP formatting & visibility: formatSkillsForLlmCompact, formatSkillsForLlmFull, formatSkillForLLMWithSchemas, filterSkillsByVisibility
  • Session mode utilities: detectSkillsOnlyMode, isSkillsOnlySession

Per coding guidelines, public API changes require corresponding docs/draft/docs/** updates. Create documentation covering these HTTP/caching/auth utilities and their usage patterns.

🤖 Fix all issues with AI agents
In `@libs/sdk/src/common/entries/tool.entry.ts`:
- Around line 87-105: The getInputJsonSchema method currently destructures
toJSONSchema from 'zod' which fails in Zod v4; update the dynamic require in
getInputJsonSchema to import only z and call
z.toJSONSchema(z.object(this.inputSchema)) so schema conversion works (refer to
getInputJsonSchema and this.inputSchema); change the types of rawInputSchema and
rawOutputSchema from any to unknown (or a more specific type) to remove any
usage; additionally, in the catch block of getInputJsonSchema add a log call to
surface conversion errors before returning the empty object fallback.

In `@libs/sdk/src/common/types/options/skills-http/schema.ts`:
- Around line 152-154: The prefix value used to build endpoint paths can be
missing a leading slash, producing malformed paths; update the prefix schema
validation (the "prefix" schema in this file) to require either an empty string
or a string that starts with '/' (e.g., add a regex check) and return a clear
validation error message, or alternatively normalize the value before use (in
the code that calls normalizeEndpointConfig for
normalizedLlmTxt/normalizedLlmFullTxt/normalizedApi) by prepending '/' when
missing; reference the "prefix" schema and the calls to normalizeEndpointConfig
(normalizedLlmTxt, normalizedLlmFullTxt, normalizedApi) when making the change.

In `@libs/sdk/src/skill/cache/skill-http-cache.factory.ts`:
- Around line 136-169: The code accepts provider === 'redis' but always
constructs an ioredis client; update the factory to handle the 'redis' provider
explicitly: when provider === 'redis' (the provider variable) lazy-require
node-redis (require('redis')), call createClient(...) with appropriate options
(host/port/password/db or a connection URL), await client.connect(), and return
a RedisSkillHttpCache with getClient async () => client; keep the existing
branch for 'vercel-kv' and the ioredis branch as the default for other providers
or alternatively remove the 'redis' option from the interface—ensure
RedisSkillHttpCache construction, the getClient symbol, and the client variable
are used consistently and that connect is awaited for node-redis.

In `@libs/sdk/src/skill/flows/http/skills-api.flow.ts`:
- Around line 237-251: The visibility check is unreliable because it compares
the loaded SkillLoadResult.skill against skillRegistry.getSkills(true) which may
return a different entry; instead, after obtaining the loaded skill (from
loadSkill or SkillLoadResult.skill) explicitly look up the canonical registry
entry via skillRegistry.getSkills(true).find(...) using the skill's unique id
(skill.id or metadata.id) and use that registry entry's metadata.visibility for
the HTTP gating; if no registry entry exists, treat the skill as not visible via
HTTP (or ensure SkillLoadResult.skill includes the registry metadata by
populating skill.metadata.visibility from the registry) so the existing
visibility check (visibility === 'mcp') reliably blocks MCP-only skills.

In `@libs/sdk/src/skill/tools/search-skills.tool.ts`:
- Around line 157-162: The hasMore flag is computed from the post-filtered array
(skills) so visibility filtering can make it false even when the registry
returned a full page; change hasMore to use the unfiltered registry response
length (results.length) instead of skills.length while keeping total =
skills.length; update the assignment for hasMore (referencing hasMore, skills,
results, and input.limit) so it reflects truncation at the source before
visibility filtering.

In `@libs/testing/src/client/mcp-test-client.ts`:
- Around line 873-879: The code currently appends query params to baseUrl inside
createTransport(), which leads to malformed URLs when StreamableHttpTransport
later constructs request URLs; instead, stop mutating baseUrl in
McpTestClient.createTransport() (leave baseUrl as this.config.baseUrl and ignore
this.config.queryParams there) and move the query string assembly into
StreamableHttpTransport.request() where the request path is built (at the point
it concatenates "/" to baseUrl), by reading the client config.queryParams and
appending ?key=val (or & when needed) after the trailing "/" so query params
come after the path separator; update references to baseUrl and queryParams in
createTransport and StreamableHttpTransport.request accordingly.
🧹 Nitpick comments (22)
libs/sdk/src/skill/cache/skill-http-cache.holder.ts (3)

96-99: Silent fallback to memory cache may hide configuration errors.

When Redis cache creation fails, the error is silently swallowed and a memory cache is returned. This could mask misconfigurations (wrong host, auth failures) that the user should be aware of. Consider logging a warning here.

♻️ Suggested improvement
-  } catch {
+  } catch (error) {
     // Fall back to memory cache on error
+    // Note: Consider adding logging here if a logger is available
     return new MemorySkillHttpCache(cacheConfig.ttlMs ?? 60000);
   }

130-136: Consider awaiting pending cache creations before disposal.

If disposeAllCaches is called while cache creation is in progress, the pending promise may resolve and attempt to use a cleared map. For robust cleanup:

♻️ Suggested improvement
 export async function disposeAllCaches(): Promise<void> {
+  // Wait for any pending creations to complete
+  await Promise.allSettled(pendingCreation.values());
+
   for (const cache of cacheByScope.values()) {
     await cache.dispose();
   }
   cacheByScope.clear();
   pendingCreation.clear();
 }

72-83: Consider reusing SkillHttpCacheRedisOptions from the factory module.

The CacheConfig.redis property mirrors SkillHttpCacheRedisOptions from skill-http-cache.factory.ts. Importing and reusing that type would reduce duplication.

♻️ Suggested change
+import type { SkillHttpCacheRedisOptions } from './skill-http-cache.factory.js';

-interface CacheConfig {
-  enabled?: boolean;
-  redis?: {
-    provider: 'redis' | 'ioredis' | 'vercel-kv' | '@vercel/kv';
-    host?: string;
-    port?: number;
-    password?: string;
-    db?: number;
-  };
-  ttlMs?: number;
-  keyPrefix?: string;
-}
+interface CacheConfig {
+  enabled?: boolean;
+  redis?: SkillHttpCacheRedisOptions;
+  ttlMs?: number;
+  keyPrefix?: string;
+}
libs/sdk/src/skill/skill-scope.helper.ts (1)

103-107: Consider initializing ownerRef inside the loop.

The ref property is initialized to undefined with an unsafe cast, then immediately overwritten in each loop iteration. Moving the object creation inside the loop would be cleaner and avoid the temporary invalid state.

♻️ Suggested improvement
-  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 ownerRef: EntryOwnerRef = {
+        kind: 'scope',
+        id: '_skills',
+        ref: SkillToolClass,
+      };

       const toolEntry = new ToolInstance(toolRecord, providers, ownerRef);
libs/sdk/src/skill/cache/skill-http-cache.ts (1)

245-278: Redis KEYS command can block the server on large keyspaces.

The keys() method (line 248, 269) uses the Redis KEYS command which is O(N) and blocks the Redis server. For production workloads with many keys, consider using SCAN instead.

The codebase already has a SCAN-based implementation in libs/utils/src/storage/adapters/redis.ts (lines 295-312) that could serve as a reference.

Additionally, the empty catch blocks silently swallow all errors during invalidation. While this may be intentional for resilience, consider logging at debug level to aid troubleshooting.

libs/sdk/src/skill/cache/skill-http-cache.factory.ts (2)

105-118: Avoid non-null assertion; narrow the type instead.

Line 110 uses options.redis! after the hasRedisProvider check. While logically safe, per coding guidelines, prefer narrowing the type explicitly.

♻️ Suggested improvement
   // Check if Redis is configured
-  if (hasRedisProvider(options.redis)) {
+  const redisConfig = options.redis;
+  if (redisConfig && hasRedisProvider(redisConfig)) {
     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);
+      const cache = await createRedisCache(redisConfig, keyPrefix, ttlMs, logger);
       logger?.verbose('Created Redis-backed skill HTTP cache', { keyPrefix, ttlMs });
       return { cache, type: 'redis' };

149-169: Consider adding connection error handling context.

The client.connect() call (line 162) is within the try block, so errors will trigger the fallback to memory cache. However, the error message in the catch (line 114-116) is generic. Adding context about which Redis operation failed would aid debugging.

♻️ Suggested improvement in createRedisCache
   await client.connect();
+  // Connection successful - return Redis cache

   return new RedisSkillHttpCache({

Or wrap connect specifically:

try {
  await client.connect();
} catch (error) {
  throw new Error(`Redis connection failed: ${error instanceof Error ? error.message : String(error)}`);
}
libs/sdk/src/transport/flows/handle.sse.flow.ts (1)

144-144: Minor type refinement suggestion.

The type cast could be slightly more precise to match detectSkillsOnlyMode's expected signature.

Optional: Align type cast with function signature
-      const query = request.query as Record<string, string | string[]> | undefined;
+      const query = request.query as Record<string, string | string[] | undefined> | undefined;
libs/sdk/src/common/metadata/skill.metadata.ts (1)

351-355: Consider relocating the type alias for better readability.

The SkillVisibility type alias is positioned between the schema section header comment and the actual schema. Moving it closer to the SkillMetadata interface (around line 291) would improve code organization.

Suggested relocation

Move the type alias to just after the SkillMetadata interface closing brace:

// After line 291 (end of SkillMetadata interface):

/**
 * Visibility mode for skill discovery.
 * Controls which mechanisms can find this skill.
 */
export type SkillVisibility = 'mcp' | 'http' | 'both';
libs/sdk/src/skill/flows/http/skills-api.flow.ts (3)

224-278: Replace any types with proper interfaces.

The skillRegistry: any and toolRegistry: any parameters violate the coding guideline to avoid any types. Use the proper interface types SkillRegistryInterface and ToolRegistryInterface (or similar) for type safety.

♻️ Suggested type improvements
-  private async handleGetSkill(skillId: string, skillRegistry: any, toolRegistry: any) {
+  private async handleGetSkill(
+    skillId: string, 
+    skillRegistry: SkillRegistryInterface, 
+    toolRegistry: ToolRegistryInterface
+  ) {

Similar changes needed for handleSearchSkills and handleListSkills methods.


280-312: Use proper types instead of any for search results.

The callback parameters use any type which reduces type safety. Consider defining an interface for search results.

-  private async handleSearchSkills(
-    query: string,
-    options: { tags?: string[]; tools?: string[]; limit?: number },
-    skillRegistry: any,
-  ) {
+  private async handleSearchSkills(
+    query: string,
+    options: { tags?: string[]; tools?: string[]; limit?: number },
+    skillRegistry: SkillRegistryInterface,
+  ) {

211-222: Non-null assertions on skillId and query violate guidelines.

Per coding guidelines, avoid non-null assertions (!) and use proper error handling instead. While the state machine ensures these are set, the assertions could mask bugs if the state logic changes.

♻️ Defensive alternative
       case 'get':
-        await this.handleGetSkill(skillId!, skillRegistry, toolRegistry);
+        if (!skillId) {
+          this.respond(httpRespond.json({ error: 'Bad Request', message: 'Missing skill ID' }, { 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' }, { status: 400 }));
+          return;
+        }
+        await this.handleSearchSkills(query, { tags, tools, limit }, skillRegistry);
         break;
apps/e2e/demo-e2e-skills/e2e/skills-http.e2e.test.ts (1)

13-40: Consider importing shared types instead of duplicating.

The SkillApiResponse, SkillsListResponse, and SkillDetailResponse interfaces are defined locally but appear to duplicate types from libs/sdk/src/skill/skill-http.utils.ts. Consider importing from the SDK to prevent type drift if the source definitions change.

+import type { SkillApiResponse } from '@frontmcp/sdk';
+
+interface SkillsListResponse {
+  skills: SkillApiResponse[];
+  total: number;
+}
+
+interface SkillDetailResponse extends SkillApiResponse {
+  instructions?: string;
+  formattedContent?: string;
+}
-interface SkillApiResponse {
-  id: string;
-  name: string;
-  // ... rest of duplicated definition
-}
libs/sdk/src/skill/__tests__/skill-http.utils.test.ts (1)

12-46: Consider typing the mock more precisely to avoid as unknown as cast.

The createMockSkillEntry function uses as unknown as SkillEntry cast at line 45. While acceptable for tests, a more precise partial mock type would improve maintainability.

apps/e2e/demo-e2e-skills/e2e/skills-only-mode.e2e.test.ts (1)

15-27: Consider using shared types instead of local interface definition.

The LoadSkillResult interface duplicates the output type structure from LoadSkillsTool. Consider importing the output type from the SDK or a shared types module to keep the test in sync with the implementation.

libs/sdk/src/skill/auth/skill-http-auth.ts (2)

131-141: Consider using timing-safe comparison for API key validation.

The direct apiKeys.includes(apiKeyHeader) comparison at lines 131 and 138 is vulnerable to timing attacks. While the risk may be low depending on context, using timing-safe comparison is a security best practice for credential validation.

🔒 Proposed fix using timing-safe comparison
+import { timingSafeEqual } from '@frontmcp/utils';
+
+// Helper function for timing-safe string comparison
+private timingSafeIncludes(keys: string[], candidate: string): boolean {
+  return keys.some(key => {
+    if (key.length !== candidate.length) return false;
+    return timingSafeEqual(Buffer.from(key), Buffer.from(candidate));
+  });
+}

 // In validateApiKey:
-    if (apiKeyHeader && apiKeys.includes(apiKeyHeader)) {
+    if (apiKeyHeader && this.timingSafeIncludes(apiKeys, apiKeyHeader)) {
       return { authorized: true };
     }

As per coding guidelines, cryptographic operations should be imported from @frontmcp/utils.


211-217: Header lookup doesn't fully handle case-insensitive matching.

The getHeader method checks both headers[name] and headers[name.toLowerCase()], but if the caller passes a lowercase name (e.g., 'authorization'), this won't find headers stored with different casing (e.g., 'Authorization'). Consider normalizing to lowercase consistently.

♻️ Proposed fix for consistent case-insensitive header lookup
 private getHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
-  const value = headers[name] ?? headers[name.toLowerCase()];
+  const lowerName = name.toLowerCase();
+  // Find header 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];
   }
   return value;
 }
libs/sdk/src/skill/flows/load-skill.flow.ts (1)

275-317: Avoid repeated tool registry scans during schema enrichment.
getTools(true) is searched per tool; pre-indexing once reduces O(n·m) work.

♻️ Suggested refactor
-    for (const { loadResult, activationResult } of loadResults) {
+    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;
       ...
       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);
+          const toolEntry = toolEntryByName?.get(t.name);
           if (toolEntry) {
             if (toolEntry.rawInputSchema) {
               result.inputSchema = toolEntry.rawInputSchema;
             }
             const rawOutput = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema;
             if (rawOutput) {
               result.outputSchema = rawOutput;
             }
           }
         }

         return result;
       });
libs/sdk/src/skill/skill.instance.ts (1)

196-204: Align sync content path with enriched metadata.
getContentSync returns base content without tags/priority/visibility; mirroring load() keeps search/visibility consistent when the sync path is used.

♻️ Suggested refactor
-    if (typeof this.metadata.instructions === 'string') {
-      return buildSkillContent(this.metadata, this.metadata.instructions);
-    }
+    if (typeof this.metadata.instructions === 'string') {
+      const baseContent = buildSkillContent(this.metadata, this.metadata.instructions);
+      this.cachedContent = {
+        ...baseContent,
+        tags: this.tags,
+        priority: this.priority,
+        hideFromDiscovery: this.hidden,
+        visibility: this.skillVisibility,
+      };
+      return this.cachedContent;
+    }
libs/sdk/src/skill/skill-http.utils.ts (1)

122-183: Pre-index tool entries to avoid repeated registry scans.
getTools(true) is searched inside the per-tool loop; caching the map once reduces overhead.

♻️ Suggested refactor
 export function formatSkillForLLMWithSchemas(
   skill: SkillContent,
   availableTools: string[],
   missingTools: string[],
   toolRegistry: ToolRegistryInterface,
 ): string {
   const parts: string[] = [];
+  const toolEntryByName = new Map(toolRegistry.getTools(true).map((t) => [t.name, t]));

   // Header
   parts.push(`# Skill: ${skill.name}`);
   ...
     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);
+        const toolEntry = toolEntryByName.get(tool.name);
         if (toolEntry) {
           const inputSchema = getToolInputSchema(toolEntry);
           const outputSchema = toolEntry.getRawOutputSchema?.() ?? toolEntry.rawOutputSchema;
           ...
         }
       }
libs/sdk/src/common/types/options/skills-http/schema.ts (2)

79-87: Consider renaming the z.infer type to avoid collision with the interface.

SkillsConfigEndpointConfig is defined in both schema.ts (via z.infer) and interfaces.ts (as an interface), but with different semantics:

  • Interface version: enabled?: boolean (optional)
  • z.infer version: enabled: boolean (required, due to .default(true))

The barrel exports the interface version, so direct imports from schema.ts would yield a different type. Consider renaming this to SkillsConfigEndpointConfigOutput or similar to distinguish it from the input interface.

Suggested rename
 /**
  * Skills HTTP endpoint config type (with defaults applied).
  */
-export type SkillsConfigEndpointConfig = z.infer<typeof skillsConfigEndpointConfigSchema>;
+export type SkillsConfigEndpointConfigOutput = z.infer<typeof skillsConfigEndpointConfigSchema>;

145-148: Redundant null coalescing for auth.

The auth field already has .default('inherit') in the schema (line 59), so parsed.auth will always be defined after parsing. The ?? 'inherit' fallback is redundant.

Remove redundant fallback
   const parsed = skillsConfigOptionsSchema.parse(options ?? {});
   const prefix = parsed.prefix ?? '';
-  const auth = parsed.auth ?? 'inherit';
+  const auth = parsed.auth;
   const apiKeys = parsed.apiKeys;

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts (1)

227-243: Use proper null safety in tests instead of non-null assertions.

Lines 240 and 242 use non-null assertions (!) after checking the tool is defined. Replace with optional chaining or conditional guards for cleaner test code.

Suggested fix
       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');

Avoid non-null assertions per coding guidelines; optional chaining is more readable here.

apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts (1)

194-230: Avoid non-null assertions in tool and parameter checks.

Use explicit guards for githubGetPr and skill.parameters to keep strict TypeScript happy and improve error clarity.

Suggested fix
       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');
+      if (!githubGetPr) {
+        throw new Error('Expected github_get_pr tool to be present');
+      }
+      expect(githubGetPr.purpose).toBeDefined();
+      expect(githubGetPr.purpose).toContain('Fetch PR details');
-      const prUrlParam = skill.parameters!.find((p) => p.name === 'pr_url');
+      if (!skill.parameters) {
+        throw new Error('Expected skill parameters to be present');
+      }
+      const prUrlParam = skill.parameters.find((p) => p.name === 'pr_url');
       expect(prUrlParam).toBeDefined();
-      expect(prUrlParam!.required).toBe(true);
-      expect(prUrlParam!.type).toBe('string');
+      if (!prUrlParam) {
+        throw new Error('Expected pr_url parameter to be present');
+      }
+      expect(prUrlParam.required).toBe(true);
+      expect(prUrlParam.type).toBe('string');

Per coding guidelines, avoid non-null assertions (!) in TypeScript files. Use explicit error guards instead.

🤖 Fix all issues with AI agents
In `@apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts`:
- Around line 280-292: The test uses a non-null assertion on the optional field
combinedWarnings (e.g., in the test calling loadSkills and checking
content.summary.combinedWarnings!), so change the assertion to safely handle the
optional: destructure combinedWarnings from content.summary (or check
content.summary first) and assert that combinedWarnings is an array and then
that combinedWarnings.some(...) is true (or explicitly fail if combinedWarnings
is undefined). Apply the same pattern to the other occurrences referenced (the
assertions around lines 207–208 and 226–229) to remove all `!` non-null
assertions and replace with a guarded check before calling .some().
🧹 Nitpick comments (5)
libs/sdk/src/skill/flows/load-skill.flow.ts (3)

103-103: Consider using z.custom<T>() instead of type assertion on z.unknown().

The current pattern z.unknown().optional() as z.ZodType<...> works but the type assertion can be made cleaner with z.custom<T>(), which explicitly signals that runtime validation is intentionally skipped for this internal state field.

Suggested change
-  loadResults: z.unknown().optional() as z.ZodType<LoadResultWithActivation[] | undefined>,
+  loadResults: z.custom<LoadResultWithActivation[] | undefined>().optional(),

232-247: Move setPolicyMode call outside the loop.

setPolicyMode appears to be a session-level setting rather than per-skill. Calling it inside the loop is redundant (same value each iteration) and slightly misleading. Moving it before or after the loop clarifies intent and avoids redundant calls.

Suggested refactor
+    // Override policy mode if specified (session-level setting)
+    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,
         allowedTools: activationResult.availableTools,
       });
     }

303-315: Consider caching the tool registry lookup to avoid repeated iteration.

toolRegistry.getTools(true) is called inside the nested loop for every tool in every skill. Building a lookup Map once before the loop would reduce complexity from O(skills × tools × registrySize) to O(registrySize + skills × tools).

Suggested optimization
     const toolRegistry = (this.scope as Scope).tools;
     const skillResults: z.infer<typeof skillResultSchema>[] = [];
     let totalTools = 0;
     let allToolsAvailable = true;
+
+    // Build tool lookup map once
+    const toolLookup = toolRegistry
+      ? new Map(toolRegistry.getTools(true).map((t) => [t.name, t]))
+      : undefined;

     for (const { loadResult, activationResult } of loadResults) {
       // ... existing code ...

       const tools = skill.tools.map((t) => {
         const isAvailable = availableTools.includes(t.name);
         const result: { /* ... */ } = { /* ... */ };

         // Include schemas for available tools
-        if (isAvailable && toolRegistry) {
-          const toolEntry = toolRegistry.getTools(true).find((te) => te.name === t.name);
+        if (isAvailable && toolLookup) {
+          const toolEntry = toolLookup.get(t.name);
           if (toolEntry) {
             // ... existing schema extraction ...
           }
         }

         return result;
       });
apps/e2e/demo-e2e-skills/e2e/plugin-skills.e2e.test.ts (1)

27-56: Consider centralizing SkillResult/LoadSkillsResult test typings.

These interfaces are repeated across multiple e2e suites; extracting them into a shared test helper reduces drift.

apps/e2e/demo-e2e-skills/e2e/load-skill.e2e.test.ts (1)

14-50: Align SkillParameter typing with SDK metadata to prevent drift.

The SDK's SkillParameter type has a union type for type field and includes a default field. Mirroring it here keeps test typings consistent with the SDK.

Suggested typing alignment
 interface SkillParameter {
   name: string;
   description?: string;
   required?: boolean;
-  type?: string;
+  type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
+  default?: unknown;
 }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libs/sdk/src/skill/tools/search-skills.tool.ts (1)

22-43: Update searchSkills output schema in SDK docs.

The documented response in docs/draft/docs/servers/skills.mdx is missing two fields now present in the actual implementation:

  • canExecute (per skill): Whether all required tools are available
  • guidance (root level): Suggested next action based on search results

Update the JSON example under the searchSkills section to include these fields and their descriptions to match the current schema.

🤖 Fix all issues with AI agents
In `@libs/sdk/src/common/types/options/skills-http/interfaces.ts`:
- Around line 151-161: The example is ambiguous about whether
skillsConfig.prefix is prepended to child paths or if llmTxt.path is absolute;
update the docs and example to state the precedence explicitly and make the
example consistent: in the FrontMcp sample clarify that skillsConfig.prefix is
applied to relative child paths and that llmTxt.path can be absolute to override
the prefix (or vice versa), and then adjust the llmTxt.path value or add a
comment to show the intended behavior (reference: FrontMcp, skillsConfig,
prefix, llmTxt.path).
- Around line 175-300: The pull request added the SkillsConfigOptions API
(symbols: SkillsConfigOptions, enabled, prefix, auth, apiKeys, jwt, llmTxt,
llmFullTxt, api, mcpTools, cache) but no docs were added; update the docs/draft
site by adding a new section (or file docs/draft/docs/servers/skills.mdx) that
documents each option, example configurations for enabling endpoints and prefix
usage, auth modes ('inherit'|'public'|'api-key'|'bearer') with examples showing
apiKeys and JWT fields, examples for llmTxt/llmFullTxt/api endpoint toggles, how
to disable mcpTools, and cache setup (memory and Redis examples) so the SDK
change is fully documented for publishable changes.

In `@libs/sdk/src/skill/flows/http/skills-api.flow.ts`:
- Around line 171-183: The request query parsing can receive arrays and produce
NaN for numeric params; update the normalization for
query/tags/tools/limit/offset: coerce query to a single string (if
request.query['query'] is string[] join by ',' or take first), ensure
tagsParam/toolsParam are normalized to string[] (if string split by ',' and
trim, filter out empty strings), and parse limitParam/offsetParam only after
validating they are numeric (use Number.isFinite on Number(value) or /^\d+$/
test) falling back to undefined or a default when invalid; adjust the variables
query, tags, tools, limit, offset accordingly so downstream pagination/search
won't receive NaN or unexpected types.
- Around line 272-276: The map callback uses an unnecessary any type; remove the
explicit "(t: any)" and let TypeScript infer the correct type from skill.tools
(typed as Array<{ name: string; purpose?: string; required?: boolean }>), i.e.,
change the mapping in the tools assignment so it uses a properly typed parameter
(or no explicit type) in the arrow function that builds name/purpose/available,
referencing the current mapping expression where tools: skill.tools.map(...) is
defined.
- Around line 214-223: Replace the non-null assertions for skillId and query
with explicit guards that return a 400 response when missing: before calling
handleGetSkill(skillId!, ...) check if skillId is undefined and call the same
error response helper used elsewhere in this file (match the existing validation
pattern) and return; similarly, before calling handleSearchSkills(query!, ...)
check if query is undefined and respond with a 400 error and return; update the
switch cases for 'get' and 'search' to perform these checks and only call
handleGetSkill and handleSearchSkills when the validated parameter is present.
🧹 Nitpick comments (4)
libs/sdk/src/common/entries/tool.entry.ts (1)

87-121: Consider adding runtime validation before casting rawInputSchema.

Line 90 casts rawInputSchema directly to Record<string, unknown> without verifying it's actually an object. If rawInputSchema is a non-object value (e.g., a string or number), this could cause unexpected behavior downstream.

🛠️ Suggested improvement
   getInputJsonSchema(): Record<string, unknown> | null {
     // Prefer rawInputSchema if already in JSON Schema format
     if (this.rawInputSchema) {
+      if (typeof this.rawInputSchema === 'object' && this.rawInputSchema !== null) {
         return this.rawInputSchema as Record<string, unknown>;
+      }
+      // rawInputSchema exists but isn't an object - fall through to conversion
     }
libs/sdk/src/skill/cache/skill-http-cache.factory.ts (1)

151-171: Consider documenting connection lifecycle expectations for callers.

The Redis client is created with lazyConnect: true but connect() is called immediately. This works correctly, but callers should be aware that the returned cache holds an active Redis connection that should be disposed via cache.dispose() when no longer needed. Consider adding a JSDoc note about connection lifecycle.

libs/sdk/src/skill/cache/skill-http-cache.holder.ts (1)

90-107: Inconsistent logging: console.warn vs logger.

The factory (createSkillHttpCache) accepts a logger parameter, but createCacheForScope uses console.warn directly. Consider passing the scope's logger to createSkillHttpCache for consistent logging behavior.

♻️ Suggested improvement
-async function createCacheForScope(scopeId: string, cacheConfig: CacheConfig): Promise<SkillHttpCache> {
+async function createCacheForScope(
+  scopeId: string,
+  cacheConfig: CacheConfig,
+  logger?: FrontMcpLogger,
+): Promise<SkillHttpCache> {
   try {
     const { cache } = await createSkillHttpCache({
       redis: cacheConfig.redis,
       ttlMs: cacheConfig.ttlMs,
       keyPrefix: cacheConfig.keyPrefix ?? `frontmcp:skills:${scopeId}:cache:`,
+      logger,
     });
     return cache;
   } catch (error) {
-    console.warn(
-      `[SkillHttpCache] Failed to create Redis cache for scope "${scopeId}", falling back to memory cache:`,
-      error instanceof Error ? error.message : String(error),
-    );
+    logger?.warn(`Failed to create Redis cache for scope "${scopeId}", falling back to memory cache`, {
+      error: error instanceof Error ? error.message : String(error),
+    });
     return new MemorySkillHttpCache(cacheConfig.ttlMs ?? 60000);
   }
 }

Then update the call site to pass scope.logger:

-  const creationPromise = createCacheForScope(scopeId, cacheConfig);
+  const creationPromise = createCacheForScope(scopeId, cacheConfig, scope.logger);
libs/sdk/src/skill/flows/load-skill.flow.ts (1)

232-241: Move policy mode setup outside the loop to avoid redundant calls.

activateSkill doesn't use policy mode to compute allowedTools (those come from skill content). However, setting policy mode once before the loop is more efficient than calling setPolicyMode for each skill activation. This ensures consistent policy across all activations without redundant updates.

🛠️ Proposed fix
+    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);
-      }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@libs/sdk/src/skill/flows/load-skill.flow.ts`:
- Around line 291-316: When building the tools array in the skill loader,
inputSchema is only assigned from toolEntry.rawInputSchema, so Zod-defined tools
that expose a getInputJsonSchema() are omitted; update the mapping inside the
tools construction (the block that references toolEntry, toolEntryByName, and
result) to set result.inputSchema to toolEntry.rawInputSchema if present,
otherwise call toolEntry.getInputJsonSchema?.() and assign that value when
available, similar to how rawOutput/getRawOutputSchema are handled for
outputSchema.
♻️ Duplicate comments (1)
libs/sdk/src/common/entries/tool.entry.ts (1)

87-118: Fix Zod v4 JSON Schema conversion path.
require('zod/v4/core') is likely to throw in Zod v4 where toJSONSchema is exposed on z, causing the method to return the empty fallback for most tools. This breaks schema exposure for HTTP/LLM consumers.

🔧 Proposed fix
-        const { z } = require('zod');
-        let toJSONSchema: (schema: unknown) => Record<string, unknown>;
-        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));
+        const { z } = require('zod');
+        if (typeof z.toJSONSchema === 'function') {
+          return z.toJSONSchema(z.object(this.inputSchema)) as Record<string, unknown>;
+        }
+        return { type: 'object', properties: {} };
In Zod v4, where is toJSONSchema exported? Is it accessed via z.toJSONSchema or via zod/v4/core?
🧹 Nitpick comments (2)
libs/sdk/src/tool/tool.utils.ts (1)

449-467: Prefer tool.getInputJsonSchema() to avoid unsafe casts and missed conversions.
This avoids casting unknown blindly and ensures Zod-based schemas are converted consistently via the new ToolEntry API.

♻️ Proposed refactor
-    let parameters: Record<string, unknown>;
-    if (tool.rawInputSchema) {
-      // Already converted to JSON Schema
-      parameters = tool.rawInputSchema as Record<string, unknown>;
-    } 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
-      try {
-        parameters = toJSONSchema(z.object(tool.inputSchema)) as Record<string, unknown>;
-      } catch {
-        parameters = { type: 'object', properties: {} };
-      }
-    } else {
-      // No schema defined - use empty object schema
-      parameters = { type: 'object', properties: {} };
-    }
+    const parameters =
+      tool.getInputJsonSchema?.() ??
+      (tool.inputSchema && Object.keys(tool.inputSchema).length > 0
+        ? (toJSONSchema(z.object(tool.inputSchema)) as Record<string, unknown>)
+        : { type: 'object', properties: {} });
libs/sdk/src/skill/flows/load-skill.flow.ts (1)

52-58: Tighten SkillParameter.type to match metadata union.
The metadata defines a fixed set of types; using an enum keeps the output schema aligned and self-validating.

♻️ Suggested adjustment
-        type: z.string().optional(),
+        type: z.enum(['string', 'number', 'boolean', 'object', 'array']).optional(),

@frontegg-david frontegg-david merged commit 5540a3a into main Jan 26, 2026
36 checks passed
@frontegg-david frontegg-david deleted the improve-skills branch January 26, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants