diff --git a/docs/client.md b/docs/client.md index c2ec9342d..d6b40a55d 100644 --- a/docs/client.md +++ b/docs/client.md @@ -498,7 +498,7 @@ var client = McpClient.sync(transport) ### Pagination -`listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` all accept an optional opaque `cursor` string, and their results carry a `nextCursor` that is non-null while more pages remain. Loop until `nextCursor` is `null` to collect every page: +`listTools`, `listResources`, `listResourceTemplates`, `listPrompts`, and `listSkills` all accept an optional opaque `cursor` string, and their results carry a `nextCursor` that is non-null while more pages remain. Loop until `nextCursor` is `null` to collect every page: ```java List allTools = new ArrayList<>(); @@ -542,6 +542,40 @@ Resources represent server-side data sources that clients can access using URI t .subscribe(); ``` +### Skills Access + +Servers that declare the `io.modelcontextprotocol/skills` extension expose Agent Skills as ordinary resources. After initialization, use `listSkills()` to discover the available skill entries and `getSkill(uri)` to retrieve the current entry for a known `SKILL.md` URI. + +The entry contains the skill's `SKILL.md` URI, its verbatim frontmatter, and either a manifest of `SkillResource` values (URI, digest, and size) or `SkillResources.dynamicResources()`. Read `SKILL.md` and supporting files with the standard resource API; `readSkillUri(uri)` is a convenience for `readResource`: + +=== "Sync API" + + ```java + ListSkillsResult skills = client.listSkills(); + McpSchema.Skill skill = skills.skills().get(0); + + // Retrieves a listed skill's current manifest, or a skill URI obtained elsewhere. + McpSchema.GetSkillResult result = client.getSkill(skill.uri()); + + // Skill files are regular resources, including SKILL.md. + ReadResourceResult skillInstructions = client.readSkillUri(result.skill().uri()); + ``` + +=== "Async API" + + ```java + client.listSkills() + .flatMap(skills -> client.getSkill(skills.skills().get(0).uri())) + .flatMap(skill -> client.readSkillUri(skill.skill().uri())) + .subscribe(); + ``` + +If the server declares `directoryRead: true` in this extension's capabilities, `readDirectory(uri)` lists all direct children of a directory resource. The SDK checks this capability before sending `resources/directory/read`: + +```java +ListResourcesResult templates = client.readDirectory("skill://pdf-processing/templates"); +``` + ### Resource Subscriptions When the server advertises `resources.subscribe` support, clients can subscribe to individual resources and receive a callback whenever the server pushes a `notifications/resources/updated` notification for that URI. The SDK automatically re-reads the resource on notification and delivers the updated contents to the registered consumer. diff --git a/docs/server.md b/docs/server.md index 5948f4ade..618ea9935 100644 --- a/docs/server.md +++ b/docs/server.md @@ -600,6 +600,63 @@ var binaryResourceSpecification = new McpServerFeatures.SyncResourceSpecificatio `ReadResourceResult` accepts a list mixing `TextResourceContents` and `BlobResourceContents`, so a single resource read can return multiple representations if needed. +### Skills Specification + +The Skills extension represents an Agent Skill as a `SKILL.md` resource plus an entry containing its frontmatter and a file manifest. It requires the normal `resources` capability and declares `io.modelcontextprotocol/skills` in `ServerCapabilities.extensions`. Set `directoryRead` to `true` only when clients may call `resources/directory/read`. + +The stateless server API registers entries at build time with `skills(...)`, or later with `addSkill`. Register resource handlers for `SKILL.md` and every supporting file as well: clients retrieve their contents through the standard `resources/read` method. The following synchronous example advertises a static skill whose only file is `SKILL.md`: + +```java +var skillUri = "skill://pdf-processing/SKILL.md"; +var skillFile = new McpStatelessServerFeatures.SyncResourceSpecification( + Resource.builder(skillUri, "SKILL.md") + .mimeType("text/markdown") + .build(), + (exchange, request) -> ReadResourceResult.builder(List.of( + McpSchema.TextResourceContents.builder(skillUri, + "---\nname: pdf-processing\ndescription: Process PDF documents\n---\n") + .mimeType("text/markdown") + .build())) + .build()); + +var skill = new McpSchema.Skill( + skillUri, + McpSchema.SkillFrontmatter.of(Map.of( + "name", "pdf-processing", + "description", "Process PDF documents")), + McpSchema.SkillResources.manifest(List.of( + new McpSchema.SkillResource(skillUri, + "sha256:0ba2bf2df2af28aa6da64011be00042083a1b87bd0979a6d45e0b7bbf149e6a4", + 64L)))); + +// A stateless server built with the capabilities below: +server.addResource(skillFile); +server.addSkill(skill); +``` + +Configure the capabilities when building that server: + +```java +ServerCapabilities capabilities = ServerCapabilities.builder() + .resources(false, false) + .extensions(Map.of( + "io.modelcontextprotocol/skills", Map.of("directoryRead", true))) + .build(); +``` + +`skills/list` returns every registered entry and `skills/get` retrieves one by its `SKILL.md` URI. A static manifest must contain every served file, including `SKILL.md`, with its digest and byte size. Use `SkillResources.dynamicResources()` when the skill's files are generated dynamically. When `directoryRead` is enabled, clients can list a skill directory's direct children. + +For resources registered with `addResource` or `resources(...)`, the server derives directory resources and their direct children automatically. A generated or non-enumerable tree must provide its own directory listing handler; it receives the request URI and cursor and returns the corresponding page: + +```java +McpStatelessAsyncServer server = McpServer.async(transport) + .serverInfo("my-server", "1.0.0") + .capabilities(capabilities) + .directoryReadHandler((context, request) -> + listGeneratedDirectory(request.uri(), request.cursor())) + .build(); +``` + ### Resource Subscriptions When the `subscribe` capability is enabled, clients can subscribe to specific resources and receive targeted `notifications/resources/updated` notifications when those resources change. Only sessions that have explicitly subscribed to a given URI receive the notification — not every connected client. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 3509b760b..5570fd2d5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -32,7 +32,10 @@ import io.modelcontextprotocol.spec.McpSchema.ElicitUrlRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.GetSkillRequest; +import io.modelcontextprotocol.spec.McpSchema.GetSkillResult; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; +import io.modelcontextprotocol.spec.McpSchema.ListSkillsResult; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest; @@ -896,6 +899,63 @@ public Mono readResource(McpSchema.ReadResourceReq }); } + /** + * Reads a skill resource through the standard {@code resources/read} method. + * @param uri The skill resource URI, including a skill's {@code SKILL.md} URI + * @return A Mono that emits the resource content. + */ + public Mono readSkillUri(String uri) { + return this.readResource(McpSchema.ReadResourceRequest.builder(uri).build()); + } + + /** + * Lists every direct child of a directory resource. This method is available only + * when the server's Skills extension declares {@code directoryRead: true}. + * @param uri The directory resource URI + * @return A Mono that emits all direct children of the directory. + */ + public Mono readDirectory(String uri) { + return this.readDirectory(uri, McpSchema.FIRST_PAGE).expand(result -> { + String next = result.nextCursor(); + return (next != null && !next.isEmpty()) ? this.readDirectory(uri, next) : Mono.empty(); + }).reduce(new ArrayList(), (accumulated, result) -> { + accumulated.addAll(result.resources()); + return accumulated; + }).map(all -> McpSchema.ListResourcesResult.builder(Collections.unmodifiableList(all)).build()); + } + + /** + * Lists one page of direct children of a directory resource. + * @param uri The directory resource URI + * @param cursor Optional pagination cursor from a previous directory read + * @return A Mono that emits one page of directory children. + */ + public Mono readDirectory(String uri, String cursor) { + return this.readDirectory(uri, cursor, null); + } + + /** + * Lists one page of direct children of a directory resource with optional metadata. + * @param uri The directory resource URI + * @param cursor Optional pagination cursor from a previous directory read + * @param meta Optional metadata to include in the request ({@code _meta} field) + * @return A Mono that emits one page of directory children. + */ + public Mono readDirectory(String uri, String cursor, Map meta) { + return this.initializer.withInitialization("reading resource directories", init -> { + if (init.initializeResult().capabilities().resources() == null) { + return Mono.error(new IllegalStateException("Server does not provide the resources capability")); + } + if (!init.initializeResult().capabilities().skillsDirectoryReadEnabled()) { + return Mono.error( + new IllegalStateException("Server does not declare Skills extension directoryRead capability")); + } + return init.mcpSession() + .sendRequest(McpSchema.METHOD_RESOURCES_DIRECTORY_READ, + new McpSchema.ReadDirectoryRequest(uri, cursor, meta), LIST_RESOURCES_RESULT_TYPE_REF); + }); + } + /** * Retrieves the list of all resource templates provided by the server. Resource * templates allow servers to expose parameterized resources using URI templates, @@ -1087,6 +1147,74 @@ private NotificationHandler asyncPromptsChangeNotificationHandler( .then()); } + // -------------------------- + // Skills Extension + // -------------------------- + private static final TypeRef LIST_SKILLS_RESULT_TYPE_REF = new TypeRef<>() { + }; + + private static final TypeRef GET_SKILL_RESULT_TYPE_REF = new TypeRef<>() { + }; + + /** + * Retrieves every skill exposed by a server supporting the Skills extension. + * @return A Mono that emits the complete list of skills. + */ + public Mono listSkills() { + return this.listSkills(McpSchema.FIRST_PAGE).expand(result -> { + String next = result.nextCursor(); + return (next != null && !next.isEmpty()) ? this.listSkills(next) : Mono.empty(); + }).reduce(new ArrayList(), (accumulated, result) -> { + accumulated.addAll(result.skills()); + return accumulated; + }).map(all -> McpSchema.ListSkillsResult.builder(Collections.unmodifiableList(all)).build()); + } + + /** + * Retrieves one page of skills exposed by a server supporting the Skills extension. + * @param cursor Optional pagination cursor from a previous list request + * @return A Mono that emits the page of skills. + */ + public Mono listSkills(String cursor) { + return this.listSkillsInternal(cursor, null); + } + + /** + * Retrieves one page of skills, including optional request metadata. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request ({@code _meta} field) + * @return A Mono that emits the page of skills. + */ + public Mono listSkills(String cursor, Map meta) { + return this.listSkillsInternal(cursor, meta); + } + + private Mono listSkillsInternal(String cursor, Map meta) { + return this.initializer.withInitialization("listing skills", + init -> init.mcpSession() + .sendRequest(McpSchema.METHOD_SKILLS_LIST, new PaginatedRequest(cursor, meta), + LIST_SKILLS_RESULT_TYPE_REF)); + } + + /** + * Retrieves the current entry for a skill URI, including its manifest. + * @param uri The {@code SKILL.md} URI of the skill + * @return A Mono that emits the skill entry. + */ + public Mono getSkill(String uri) { + return this.getSkill(new GetSkillRequest(uri)); + } + + /** + * Retrieves the current entry for a skill URI, including its manifest. + * @param getSkillRequest The request containing the {@code SKILL.md} URI + * @return A Mono that emits the skill entry. + */ + public Mono getSkill(GetSkillRequest getSkillRequest) { + return this.initializer.withInitialization("getting skills", init -> init.mcpSession() + .sendRequest(McpSchema.METHOD_SKILLS_GET, getSkillRequest, GET_SKILL_RESULT_TYPE_REF)); + } + // -------------------------- // Logging // -------------------------- diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index 7e08f83a0..53f21cc20 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -16,7 +16,10 @@ import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.GetSkillRequest; +import io.modelcontextprotocol.spec.McpSchema.GetSkillResult; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; +import io.modelcontextprotocol.spec.McpSchema.ListSkillsResult; import io.modelcontextprotocol.util.Assert; import reactor.core.publisher.Mono; @@ -326,6 +329,50 @@ public McpSchema.ReadResourceResult readResource(McpSchema.ReadResourceRequest r } + /** + * Reads a skill resource through the standard {@code resources/read} method. + * @param uri The skill resource URI, including a skill's {@code SKILL.md} URI + * @return The resource content. + */ + public McpSchema.ReadResourceResult readSkillUri(String uri) { + return withProvidedContext(this.delegate.readSkillUri(uri)).block(); + + } + + /** + * Lists every direct child of a directory resource. This method is available only + * when the server's Skills extension declares {@code directoryRead: true}. + * @param uri The directory resource URI + * @return All direct children of the directory. + */ + public McpSchema.ListResourcesResult readDirectory(String uri) { + return withProvidedContext(this.delegate.readDirectory(uri)).block(); + + } + + /** + * Lists one page of direct children of a directory resource. + * @param uri The directory resource URI + * @param cursor Optional pagination cursor from a previous directory read + * @return One page of directory children. + */ + public McpSchema.ListResourcesResult readDirectory(String uri, String cursor) { + return withProvidedContext(this.delegate.readDirectory(uri, cursor)).block(); + + } + + /** + * Lists one page of direct children of a directory resource with optional metadata. + * @param uri The directory resource URI + * @param cursor Optional pagination cursor from a previous directory read + * @param meta Optional metadata to include in the request ({@code _meta} field) + * @return One page of directory children. + */ + public McpSchema.ListResourcesResult readDirectory(String uri, String cursor, Map meta) { + return withProvidedContext(this.delegate.readDirectory(uri, cursor, meta)).block(); + + } + /** * Retrieves the list of all resource templates provided by the server. * @return The list of all resource templates result. @@ -423,6 +470,55 @@ public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) { return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block(); } + // -------------------------- + // Skills Extension + // -------------------------- + + /** + * Retrieves every skill exposed by a server supporting the Skills extension. + * @return The complete list of skills. + */ + public ListSkillsResult listSkills() { + return withProvidedContext(this.delegate.listSkills()).block(); + } + + /** + * Retrieves one page of skills exposed by a server supporting the Skills extension. + * @param cursor Optional pagination cursor from a previous list request + * @return The page of skills. + */ + public ListSkillsResult listSkills(String cursor) { + return withProvidedContext(this.delegate.listSkills(cursor)).block(); + } + + /** + * Retrieves one page of skills, including optional request metadata. + * @param cursor Optional pagination cursor from a previous list request + * @param meta Optional metadata to include in the request ({@code _meta} field) + * @return The page of skills. + */ + public ListSkillsResult listSkills(String cursor, Map meta) { + return withProvidedContext(this.delegate.listSkills(cursor, meta)).block(); + } + + /** + * Retrieves the current entry for a skill URI, including its manifest. + * @param uri The {@code SKILL.md} URI of the skill + * @return The skill entry. + */ + public GetSkillResult getSkill(String uri) { + return withProvidedContext(this.delegate.getSkill(uri)).block(); + } + + /** + * Retrieves the current entry for a skill URI, including its manifest. + * @param getSkillRequest The request containing the {@code SKILL.md} URI + * @return The skill entry. + */ + public GetSkillResult getSkill(GetSkillRequest getSkillRequest) { + return withProvidedContext(this.delegate.getSkill(getSkillRequest)).block(); + } + /** * Client can set the minimum logging level it wants to receive from the server. * @param loggingLevel the min logging level diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index 2113fdb48..f2ed8c5ef 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -1542,6 +1542,13 @@ class StatelessAsyncSpecification { */ final Map resources = new HashMap<>(); + /** + * The Skills extension lets servers advertise Agent Skills. Each skill is backed + * by a {@code SKILL.md} resource and includes frontmatter plus either a static + * resource manifest or a dynamic-resource marker. + */ + final Map skills = new HashMap<>(); + /** * The Model Context Protocol (MCP) provides a standardized way for servers to * expose resource templates to clients. Resource templates allow servers to @@ -1551,6 +1558,8 @@ class StatelessAsyncSpecification { */ final Map resourceTemplates = new HashMap<>(); + BiFunction> directoryReadHandler; + /** * The Model Context Protocol (MCP) provides a standardized way for servers to * expose prompt templates to clients. Prompts allow servers to provide structured @@ -1866,6 +1875,21 @@ public StatelessAsyncSpecification resources( return this; } + /** + * Registers skill entries served through {@code skills/list} and + * {@code skills/get}. + * @param skills the skill entries to register, must not be null + * @return this builder instance + */ + public StatelessAsyncSpecification skills(McpSchema.Skill... skills) { + Assert.notNull(skills, "Skills must not be null"); + for (McpSchema.Skill skill : skills) { + Assert.notNull(skill, "Skill must not be null"); + this.skills.put(skill.uri(), skill); + } + return this; + } + /** * Sets the resource templates that define patterns for dynamic resource access. * Templates use URI patterns with placeholders that can be filled at runtime. @@ -1900,6 +1924,20 @@ public StatelessAsyncSpecification resourceTemplates( return this; } + /** + * Sets the handler for {@code resources/directory/read}. Use this for dynamic or + * non-enumerable resource trees; the handler receives every directory request and + * is responsible for validating the URI and applying pagination. + * @param directoryReadHandler the directory read handler, must not be null + * @return this builder instance + */ + public StatelessAsyncSpecification directoryReadHandler( + BiFunction> directoryReadHandler) { + Assert.notNull(directoryReadHandler, "Directory read handler must not be null"); + this.directoryReadHandler = directoryReadHandler; + return this; + } + /** * Registers multiple prompts with their handlers using a Map. This method is * useful when prompts are dynamically generated or loaded from a configuration @@ -2024,8 +2062,8 @@ public StatelessAsyncSpecification jsonSchemaValidator(JsonSchemaValidator jsonS public McpStatelessAsyncServer build() { var features = new McpStatelessServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, - this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions, - this.toolFilters); + this.resources, this.skills, this.resourceTemplates, this.prompts, this.completions, + this.instructions, this.toolFilters, this.directoryReadHandler); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); @@ -2079,6 +2117,13 @@ class StatelessSyncSpecification { */ final Map resources = new HashMap<>(); + /** + * The Skills extension lets servers advertise Agent Skills. Each skill is backed + * by a {@code SKILL.md} resource and includes frontmatter plus either a static + * resource manifest or a dynamic-resource marker. + */ + final Map skills = new HashMap<>(); + /** * The Model Context Protocol (MCP) provides a standardized way for servers to * expose resource templates to clients. Resource templates allow servers to @@ -2088,6 +2133,8 @@ class StatelessSyncSpecification { */ final Map resourceTemplates = new HashMap<>(); + BiFunction directoryReadHandler; + /** * The Model Context Protocol (MCP) provides a standardized way for servers to * expose prompt templates to clients. Prompts allow servers to provide structured @@ -2405,6 +2452,21 @@ public StatelessSyncSpecification resources( return this; } + /** + * Registers skill entries served through {@code skills/list} and + * {@code skills/get}. + * @param skills the skill entries to register, must not be null + * @return this builder instance + */ + public StatelessSyncSpecification skills(McpSchema.Skill... skills) { + Assert.notNull(skills, "Skills must not be null"); + for (McpSchema.Skill skill : skills) { + Assert.notNull(skill, "Skill must not be null"); + this.skills.put(skill.uri(), skill); + } + return this; + } + /** * Sets the resource templates that define patterns for dynamic resource access. * Templates use URI patterns with placeholders that can be filled at runtime. @@ -2439,6 +2501,20 @@ public StatelessSyncSpecification resourceTemplates( return this; } + /** + * Sets the handler for {@code resources/directory/read}. Use this for dynamic or + * non-enumerable resource trees; the handler receives every directory request and + * is responsible for validating the URI and applying pagination. + * @param directoryReadHandler the directory read handler, must not be null + * @return this builder instance + */ + public StatelessSyncSpecification directoryReadHandler( + BiFunction directoryReadHandler) { + Assert.notNull(directoryReadHandler, "Directory read handler must not be null"); + this.directoryReadHandler = directoryReadHandler; + return this; + } + /** * Registers multiple prompts with their handlers using a Map. This method is * useful when prompts are dynamically generated or loaded from a configuration @@ -2579,8 +2655,8 @@ public StatelessSyncSpecification immediateExecution(boolean immediateExecution) public McpStatelessSyncServer build() { var syncFeatures = new McpStatelessServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, - this.resources, this.resourceTemplates, this.prompts, this.completions, this.instructions, - this.toolFilters); + this.resources, this.skills, this.resourceTemplates, this.prompts, this.completions, + this.instructions, this.toolFilters, this.directoryReadHandler); var asyncFeatures = McpStatelessServerFeatures.Async.fromSync(syncFeatures, this.immediateExecution); var jsonSchemaValidator = this.jsonSchemaValidator != null ? this.jsonSchemaValidator : McpJsonDefaults.getSchemaValidator(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 95b694e7f..67c058a9e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -7,12 +7,16 @@ import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiFunction; +import java.util.regex.Pattern; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; @@ -52,6 +56,12 @@ public class McpStatelessAsyncServer { private static final Logger logger = LoggerFactory.getLogger(McpStatelessAsyncServer.class); + private static final Pattern SHA_256_DIGEST = Pattern.compile("sha256:[0-9a-f]{64}"); + + private static final int MAX_SKILL_RESOURCES = 512; + + private static final long MAX_SKILL_SIZE_BYTES = 16L * 1024 * 1024; + private final McpStatelessServerTransport mcpTransportProvider; private final McpJsonMapper jsonMapper; @@ -68,6 +78,8 @@ public class McpStatelessAsyncServer { private final ConcurrentHashMap resources = new ConcurrentHashMap<>(); + private final ConcurrentHashMap skills = new ConcurrentHashMap<>(); + private final ConcurrentHashMap prompts = new ConcurrentHashMap<>(); private final ConcurrentHashMap completions = new ConcurrentHashMap<>(); @@ -82,6 +94,8 @@ public class McpStatelessAsyncServer { private final McpAsyncListFilter toolFilter; + private final BiFunction> directoryReadHandler; + McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper, McpStatelessServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, @@ -90,9 +104,16 @@ public class McpStatelessAsyncServer { this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); this.serverCapabilities = features.serverCapabilities(); + if (this.serverCapabilities.skillsExtensionEnabled() && this.serverCapabilities.resources() == null) { + throw new IllegalArgumentException("Skills extension requires resource capabilities"); + } this.instructions = features.instructions(); this.tools.addAll(withStructuredOutputHandling(jsonSchemaValidator, features.tools())); this.resources.putAll(features.resources()); + features.skills().values().forEach(skill -> { + validateSkill(skill); + this.skills.put(skill.uri(), skill); + }); this.resourceTemplates.putAll(features.resourceTemplates()); this.prompts.putAll(features.prompts()); this.completions.putAll(features.completions()); @@ -100,6 +121,7 @@ public class McpStatelessAsyncServer { this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); + this.directoryReadHandler = features.directoryReadHandler(); Map> requestHandlers = new HashMap<>(); @@ -121,6 +143,14 @@ public class McpStatelessAsyncServer { requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler()); + if (this.serverCapabilities.skillsDirectoryReadEnabled()) { + requestHandlers.put(McpSchema.METHOD_RESOURCES_DIRECTORY_READ, resourcesDirectoryReadRequestHandler()); + } + } + + if (this.serverCapabilities.skillsExtensionEnabled()) { + requestHandlers.put(McpSchema.METHOD_SKILLS_LIST, skillsListRequestHandler()); + requestHandlers.put(McpSchema.METHOD_SKILLS_GET, skillsGetRequestHandler()); } // Add prompts API handlers if provider exists @@ -641,6 +671,189 @@ private McpStatelessRequestHandler resourcesReadRe }; } + private McpStatelessRequestHandler resourcesDirectoryReadRequestHandler() { + return (ctx, params) -> { + McpSchema.ReadDirectoryRequest directoryRequest = jsonMapper.convertValue(params, new TypeRef<>() { + }); + return this.directoryReadHandler != null ? this.directoryReadHandler.apply(ctx, directoryRequest) + : defaultDirectoryRead(directoryRequest); + }; + } + + private Mono defaultDirectoryRead(McpSchema.ReadDirectoryRequest directoryRequest) { + String directoryUri = directoryRequest.uri(); + if (!isDirectoryResource(directoryUri) && !hasResourceDescendant(directoryUri)) { + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("URI does not identify a directory resource") + .build()); + } + Map children = new LinkedHashMap<>(); + this.resources.values() + .stream() + .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) + .forEach(resource -> { + if (isDirectChild(directoryUri, resource.uri())) { + children.put(resource.uri(), resource); + } + else { + String childDirectory = directChildDirectory(directoryUri, resource.uri()); + if (childDirectory != null) { + children.putIfAbsent(childDirectory, directoryResource(childDirectory)); + } + } + }); + return Mono.just(McpSchema.ListResourcesResult.builder(List.copyOf(children.values())).build()); + } + + /** + * Adds a skill entry served through {@code skills/list} and {@code skills/get}. + * @param skill The skill entry to register + * @return A Mono that completes when the skill is registered + */ + public Mono addSkill(McpSchema.Skill skill) { + Assert.notNull(skill, "skill must not be null"); + if (!this.serverCapabilities.skillsExtensionEnabled()) { + return Mono.error(new IllegalStateException("Server must declare the Skills extension")); + } + return Mono.fromRunnable(() -> { + validateSkill(skill); + this.skills.put(skill.uri(), skill); + }); + } + + private static void validateSkill(McpSchema.Skill skill) { + String skillUri = skill.uri(); + String skillRoot = parentUri(skillUri); + if (skillRoot == null || !skillUri.endsWith("/SKILL.md")) { + throw new IllegalArgumentException("Skill URI must identify a SKILL.md resource"); + } + + String skillName = skillRoot.substring(skillRoot.lastIndexOf('/') + 1); + if (skill.frontmatter().name() == null || skill.frontmatter().name().isBlank()) { + throw new IllegalArgumentException("Skill frontmatter must contain a non-blank name"); + } + if (skill.frontmatter().description() == null || skill.frontmatter().description().isBlank()) { + throw new IllegalArgumentException("Skill frontmatter must contain a non-blank description"); + } + if (!skillName.equals(skill.frontmatter().name())) { + throw new IllegalArgumentException("Skill URI path must end with the frontmatter name"); + } + + if (skill.resources().dynamic()) { + return; + } + + List manifest = skill.resources().manifest(); + if (manifest.size() > MAX_SKILL_RESOURCES) { + throw new IllegalArgumentException( + "Skill manifest must not contain more than " + MAX_SKILL_RESOURCES + " resources"); + } + + Set resourceUris = new HashSet<>(); + long totalSize = 0; + for (McpSchema.SkillResource resource : manifest) { + if (!resourceUris.add(resource.uri())) { + throw new IllegalArgumentException("Skill manifest must not contain duplicate resource URIs"); + } + if (resource.uri().equals(skillRoot) || !isWithinDirectory(resource.uri(), skillRoot)) { + throw new IllegalArgumentException("Skill manifest resource URI must be within the skill directory"); + } + if (!SHA_256_DIGEST.matcher(resource.digest()).matches()) { + throw new IllegalArgumentException("Skill manifest digest must be a lowercase SHA-256 digest"); + } + if (resource.size() < 0) { + throw new IllegalArgumentException("Skill manifest resource size must not be negative"); + } + try { + totalSize = Math.addExact(totalSize, resource.size()); + } + catch (ArithmeticException e) { + throw new IllegalArgumentException("Skill manifest total size is too large", e); + } + } + + if (!resourceUris.contains(skillUri)) { + throw new IllegalArgumentException("Skill manifest must contain the SKILL.md resource"); + } + if (totalSize > MAX_SKILL_SIZE_BYTES) { + throw new IllegalArgumentException( + "Skill manifest total size must not exceed " + MAX_SKILL_SIZE_BYTES + " bytes"); + } + } + + /** + * Lists every skill registered with this server. + * @return A Flux stream of skill entries + */ + public Flux listSkills() { + return Flux.fromIterable(this.skills.values()); + } + + private McpStatelessRequestHandler skillsListRequestHandler() { + return (ctx, params) -> Mono + .just(McpSchema.ListSkillsResult.builder(List.copyOf(this.skills.values())).build()); + } + + private McpStatelessRequestHandler skillsGetRequestHandler() { + return (ctx, params) -> { + McpSchema.GetSkillRequest skillRequest = jsonMapper.convertValue(params, new TypeRef<>() { + }); + McpSchema.Skill skill = this.skills.get(skillRequest.uri()); + return skill != null ? Mono.just(new McpSchema.GetSkillResult(skill)) + : Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS).message("Unknown skill URI").build()); + }; + } + + private static boolean isDirectChild(String directoryUri, String resourceUri) { + if (directoryUri.endsWith("/")) { + return false; + } + String prefix = directoryUri + "/"; + if (!resourceUri.startsWith(prefix)) { + return false; + } + String relativeUri = resourceUri.substring(prefix.length()); + return !relativeUri.isEmpty() && !relativeUri.contains("/"); + } + + private static String directChildDirectory(String directoryUri, String resourceUri) { + String prefix = directoryUri + "/"; + if (!resourceUri.startsWith(prefix)) { + return null; + } + String relativeUri = resourceUri.substring(prefix.length()); + int slash = relativeUri.indexOf('/'); + return slash < 0 ? null : prefix + relativeUri.substring(0, slash); + } + + private boolean isDirectoryResource(String uri) { + return this.resources.values() + .stream() + .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) + .anyMatch(resource -> resource.uri().equals(uri) && "inode/directory".equals(resource.mimeType())); + } + + private boolean hasResourceDescendant(String directoryUri) { + String prefix = directoryUri + "/"; + return this.resources.keySet().stream().anyMatch(uri -> uri.startsWith(prefix)); + } + + private static String parentUri(String uri) { + int slash = uri.lastIndexOf('/'); + int schemeSeparator = uri.indexOf("://"); + int authorityEnd = schemeSeparator < 0 ? 0 : schemeSeparator + 2; + return slash > authorityEnd ? uri.substring(0, slash) : null; + } + + private static boolean isWithinDirectory(String uri, String directory) { + return uri.equals(directory) || uri.startsWith(directory + "/"); + } + + private static McpSchema.Resource directoryResource(String uri) { + int slash = uri.lastIndexOf('/'); + return McpSchema.Resource.builder(uri, uri.substring(slash + 1)).mimeType("inode/directory").build(); + } + private Optional findResourceSpecification(String uri) { var result = this.resources.values() .stream() diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java index 8ffc2a621..3e3d955e7 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessServerFeatures.java @@ -41,11 +41,12 @@ public class McpStatelessServerFeatures { */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, - Map resources, + Map resources, Map skills, Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions, List> toolFilters, + BiFunction> directoryReadHandler) { /** * Create an instance and validate the arguments. @@ -60,11 +61,12 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, - Map resources, + Map resources, Map skills, Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions, List> toolFilters, + BiFunction> directoryReadHandler) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -82,11 +84,13 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s this.tools = (tools != null) ? tools : List.of(); this.resources = (resources != null) ? resources : Map.of(); + this.skills = (skills != null) ? skills : Map.of(); this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : Map.of(); this.prompts = (prompts != null) ? prompts : Map.of(); this.completions = (completions != null) ? completions : Map.of(); this.instructions = instructions; this.toolFilters = (toolFilters != null) ? toolFilters : List.of(); + this.directoryReadHandler = directoryReadHandler; } /** @@ -125,12 +129,16 @@ static Async fromSync(Sync syncSpec, boolean immediateExecution) { completions.put(key, AsyncCompletionSpecification.fromSync(completion, immediateExecution)); }); - return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, - prompts, completions, syncSpec.instructions(), + return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, syncSpec.skills(), + resourceTemplates, prompts, completions, syncSpec.instructions(), syncSpec.toolFilters() .stream() .map(filter -> McpAsyncListFilter.fromSync(filter, immediateExecution)) - .toList()); + .toList(), + syncSpec.directoryReadHandler() == null ? null : (ctx, request) -> { + var result = Mono.fromCallable(() -> syncSpec.directoryReadHandler().apply(ctx, request)); + return immediateExecution ? result : result.subscribeOn(Schedulers.boundedElastic()); + }); } } @@ -149,10 +157,12 @@ static Async fromSync(Sync syncSpec, boolean immediateExecution) { record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, + Map skills, Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions, List> toolFilters, + BiFunction directoryReadHandler) { /** * Create an instance and validate the arguments. @@ -168,10 +178,12 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, + Map skills, Map resourceTemplates, Map prompts, Map completions, - String instructions, List> toolFilters) { + String instructions, List> toolFilters, + BiFunction directoryReadHandler) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -192,11 +204,13 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se this.tools = (tools != null) ? tools : new ArrayList<>(); this.resources = (resources != null) ? resources : new HashMap<>(); + this.skills = (skills != null) ? skills : Map.of(); this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : Map.of(); this.prompts = (prompts != null) ? prompts : new HashMap<>(); this.completions = (completions != null) ? completions : new HashMap<>(); this.instructions = instructions; this.toolFilters = (toolFilters != null) ? toolFilters : List.of(); + this.directoryReadHandler = directoryReadHandler; } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java index 475f88df8..d1ed4e552 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessSyncServer.java @@ -172,6 +172,22 @@ public void removePrompt(String promptName) { this.asyncServer.removePrompt(promptName).block(); } + /** + * Adds a skill entry served through {@code skills/list} and {@code skills/get}. + * @param skill The skill entry to register + */ + public void addSkill(McpSchema.Skill skill) { + this.asyncServer.addSkill(skill).block(); + } + + /** + * Lists every skill registered with this server. + * @return A list of skill entries + */ + public List listSkills() { + return this.asyncServer.listSkills().collectList().block(); + } + /** * This method is package-private and used for test only. Should not be called by user * code. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 648be8b4b..5f5d8cbff 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonValue; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.util.Assert; @@ -73,11 +74,18 @@ private McpSchema() { public static final String METHOD_NOTIFICATION_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; + // Skills Extension Methods + public static final String METHOD_SKILLS_LIST = "skills/list"; + + public static final String METHOD_SKILLS_GET = "skills/get"; + // Resources Methods public static final String METHOD_RESOURCES_LIST = "resources/list"; public static final String METHOD_RESOURCES_READ = "resources/read"; + public static final String METHOD_RESOURCES_DIRECTORY_READ = "resources/directory/read"; + public static final String METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"; public static final String METHOD_NOTIFICATION_RESOURCES_UPDATED = "notifications/resources/updated"; @@ -794,7 +802,42 @@ public record ServerCapabilities( // @formatter:off @JsonProperty("logging") LoggingCapabilities logging, @JsonProperty("prompts") PromptCapabilities prompts, @JsonProperty("resources") ResourceCapabilities resources, - @JsonProperty("tools") ToolCapabilities tools) { // @formatter:on + @JsonProperty("tools") ToolCapabilities tools, + @JsonProperty("extensions") Map extensions) { // @formatter:on + + /** + * @deprecated Use the constructor including {@code extensions}. + */ + @Deprecated + public ServerCapabilities(CompletionCapabilities completions, Map experimental, + LoggingCapabilities logging, PromptCapabilities prompts, ResourceCapabilities resources, + ToolCapabilities tools) { + this(completions, experimental, logging, prompts, resources, tools, null); + } + + /** + * Whether the Skills extension declares support for + * {@code resources/directory/read}. + * @return {@code true} only if the extension declares {@code directoryRead: true} + */ + public boolean skillsDirectoryReadEnabled() { + if (!(skillsExtension() instanceof Map skills)) { + return false; + } + return Boolean.TRUE.equals(skills.get("directoryRead")); + } + + /** + * Whether the server declares the SEP-2640 Skills extension. + * @return {@code true} if the Skills extension is declared + */ + public boolean skillsExtensionEnabled() { + return skillsExtension() != null; + } + + private Object skillsExtension() { + return extensions == null ? null : extensions.get("io.modelcontextprotocol/skills"); + } /** * Present if the server supports argument autocompletion suggestions. @@ -923,6 +966,7 @@ public Builder mutate() { builder.prompts = this.prompts; builder.resources = this.resources; builder.tools = this.tools; + builder.extensions = this.extensions; return builder; } @@ -944,6 +988,8 @@ public static class Builder { private ToolCapabilities tools; + private Map extensions; + public Builder completions() { this.completions = new CompletionCapabilities(); return this; @@ -974,8 +1020,14 @@ public Builder tools(Boolean listChanged) { return this; } + public Builder extensions(Map extensions) { + this.extensions = extensions; + return this; + } + public ServerCapabilities build() { - return new ServerCapabilities(completions, experimental, logging, prompts, resources, tools); + return new ServerCapabilities(completions, experimental, logging, prompts, resources, tools, + extensions); } } @@ -1796,6 +1848,44 @@ public ReadResourceRequest build() { } } + /** + * Sent from the client to list the direct children of a directory resource. + * + * @param uri The URI of the directory resource. + * @param cursor An optional pagination cursor from a previous directory read. + * @param meta See specification for notes on _meta usage. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ReadDirectoryRequest( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("cursor") String cursor, + @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + + public ReadDirectoryRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static ReadDirectoryRequest fromJson(@JsonProperty("uri") String uri, @JsonProperty("cursor") String cursor, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn( + "ReadDirectoryRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new ReadDirectoryRequest(uri, cursor, meta); + } + + public ReadDirectoryRequest(String uri, String cursor) { + this(uri, cursor, null); + } + + public ReadDirectoryRequest(String uri) { + this(uri, null, null); + } + } + /** * The server's response to a resources/read request from the client. * @@ -2473,6 +2563,303 @@ public ListPromptsResult build() { } } + // --------------------------- + // Skills Extension + // --------------------------- + /** + * A file in a skill's manifest. + * + * @param uri The resource URI of the file. + * @param digest The content digest, including its algorithm prefix. + * @param size The file size in bytes. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SkillResource( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("digest") String digest, + @JsonProperty("size") Long size) { // @formatter:on + + public SkillResource { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(digest, "digest must not be null"); + Assert.notNull(size, "size must not be null"); + } + + @JsonCreator + static SkillResource fromJson(@JsonProperty("uri") String uri, @JsonProperty("digest") String digest, + @JsonProperty("size") Long size) { + if (uri == null || digest == null || size == null) { + logger.warn("SkillResource: missing required fields during deserialization; using safe defaults"); + uri = uri == null ? "" : uri; + digest = digest == null ? "" : digest; + size = size == null ? 0L : size; + } + return new SkillResource(uri, digest, size); + } + } + + /** + * The two wire representations permitted for a skill resource manifest. + * + *

+ * The extension represents a static manifest directly as an array and a dynamic + * manifest as the string {@code "dynamic"}. The {@link JsonValue} and delegating + * creator preserve that union without adding a Java-only wrapper to the wire format. + * + * @param manifest The static resource manifest, or {@code null} when dynamic. + * @param dynamic Whether the skill's resources are generated dynamically. + */ + public record SkillResources(List manifest, boolean dynamic) { + + public SkillResources { + if (dynamic == (manifest != null)) { + throw new IllegalArgumentException("exactly one of manifest or dynamic must be set"); + } + } + + public static SkillResources manifest(List manifest) { + Assert.notNull(manifest, "manifest must not be null"); + return new SkillResources(manifest, false); + } + + public static SkillResources dynamicResources() { + return new SkillResources(null, true); + } + + @JsonValue + Object toJson() { + return dynamic ? "dynamic" : manifest; + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + static SkillResources fromJson(List manifest) { + return manifest(manifest); + } + + @JsonCreator + static SkillResources fromJson(String value) { + if (!"dynamic".equals(value)) { + throw new IllegalArgumentException("resources must be an array or 'dynamic'"); + } + return dynamicResources(); + } + } + + /** + * The verbatim YAML frontmatter of a skill, represented on the wire as a JSON object. + * + *

+ * The Agent Skills specification requires {@code name} and {@code description}, but + * permits additional fields. This type preserves every field while providing typed + * accessors for the fields required by the specification. + * + * @param values Every frontmatter field, including fields unknown to this SDK. + */ + public record SkillFrontmatter(Map values) { + + public SkillFrontmatter { + Assert.notNull(values, "frontmatter values must not be null"); + } + + public static SkillFrontmatter of(Map values) { + return new SkillFrontmatter(values); + } + + /** + * @return The required skill name, or {@code null} when a non-conforming peer + * omits it. + */ + public String name() { + return this.values.get("name") instanceof String name ? name : null; + } + + /** + * @return The required skill description, or {@code null} when a non-conforming + * peer omits it. + */ + public String description() { + return this.values.get("description") instanceof String description ? description : null; + } + + /** + * @return The optional frontmatter metadata, or {@code null} when absent or not + * an object. + */ + public Map metadata() { + if (!(this.values.get("metadata") instanceof Map metadata)) { + return null; + } + Map result = new HashMap<>(); + metadata.forEach((key, value) -> { + if (key instanceof String name) { + result.put(name, value); + } + }); + return result; + } + + @JsonValue + Map toJson() { + return this.values; + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + static SkillFrontmatter fromJson(Map values) { + return new SkillFrontmatter(values == null ? Map.of() : values); + } + } + + /** + * A skill entry returned by the Skills extension. + * + * @param uri The URI of the skill's {@code SKILL.md} resource. + * @param frontmatter The verbatim Agent Skills frontmatter. + * @param resources The static resource manifest or a dynamic-manifest marker. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Skill( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("frontmatter") SkillFrontmatter frontmatter, + @JsonProperty("resources") SkillResources resources) { // @formatter:on + + public Skill { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(frontmatter, "frontmatter must not be null"); + Assert.notNull(resources, "resources must not be null"); + } + + @JsonCreator + static Skill fromJson(@JsonProperty("uri") String uri, + @JsonProperty("frontmatter") SkillFrontmatter frontmatter, + @JsonProperty("resources") SkillResources resources) { + if (uri == null || frontmatter == null || resources == null) { + logger.warn("Skill: missing required fields during deserialization; using safe defaults"); + uri = uri == null ? "" : uri; + frontmatter = frontmatter == null ? SkillFrontmatter.of(Map.of()) : frontmatter; + resources = resources == null ? SkillResources.manifest(List.of()) : resources; + } + return new Skill(uri, frontmatter, resources); + } + + } + + /** + * The server's response to a {@code skills/list} request. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListSkillsResult( // @formatter:off + @JsonProperty("skills") List skills, + @JsonProperty("nextCursor") String nextCursor, + @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + + public ListSkillsResult { + Assert.notNull(skills, "skills must not be null"); + } + + @JsonCreator + static ListSkillsResult fromJson(@JsonProperty("skills") List skills, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (skills == null) { + logger + .warn("ListSkillsResult: missing required field 'skills' during deserialization, using default []"); + skills = List.of(); + } + return new ListSkillsResult(skills, nextCursor, meta); + } + + public static Builder builder(List skills) { + return new Builder(skills); + } + + public static class Builder { + + private final List skills; + + private String nextCursor; + + private Map meta; + + private Builder(List skills) { + Assert.notNull(skills, "skills must not be null"); + this.skills = skills; + } + + public Builder nextCursor(String nextCursor) { + this.nextCursor = nextCursor; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ListSkillsResult build() { + return new ListSkillsResult(skills, nextCursor, meta); + } + + } + } + + /** + * Parameters for a {@code skills/get} request. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record GetSkillRequest( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + + public GetSkillRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static GetSkillRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn("GetSkillRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new GetSkillRequest(uri, meta); + } + + public GetSkillRequest(String uri) { + this(uri, null); + } + } + + /** + * The server's response to a {@code skills/get} request. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record GetSkillResult( // @formatter:off + @JsonProperty("skill") Skill skill, + @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + + public GetSkillResult { + Assert.notNull(skill, "skill must not be null"); + } + + @JsonCreator + static GetSkillResult fromJson(@JsonProperty("skill") Skill skill, + @JsonProperty("_meta") Map meta) { + if (skill == null) { + logger.warn("GetSkillResult: missing required field 'skill'; using an empty skill"); + skill = new Skill("", SkillFrontmatter.of(Map.of()), SkillResources.manifest(List.of())); + } + return new GetSkillResult(skill, meta); + } + + public GetSkillResult(Skill skill) { + this(skill, null); + } + } + /** * Used by the client to get a prompt provided by the server. * diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java index 04387bd12..4d338e869 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractStatelessIntegrationTests.java @@ -73,6 +73,37 @@ void simple(String clientType) { } } + // --------------------------------------- + // Skills Tests + // --------------------------------------- + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testSkillListingAndRetrieval(String clientType) { + var clientBuilder = clientBuilders.get(clientType); + McpSchema.Skill skill = new McpSchema.Skill("skill://data-analysis/SKILL.md", + McpSchema.SkillFrontmatter.of(Map.of("name", "data-analysis", "description", "Analyze tabular data.")), + McpSchema.SkillResources.dynamicResources()); + + McpStatelessSyncServer mcpServer = prepareSyncServerBuilder() + .capabilities(ServerCapabilities.builder() + .resources(false, false) + .extensions(Map.of("io.modelcontextprotocol/skills", Map.of())) + .build()) + .build(); + + try (var mcpClient = clientBuilder.build()) { + assertThat(mcpClient.initialize()).isNotNull(); + + mcpServer.addSkill(skill); + + assertThat(mcpClient.listSkills().skills()).containsExactly(skill); + assertThat(mcpClient.getSkill(skill.uri()).skill()).isEqualTo(skill); + } + finally { + mcpServer.closeGracefully(); + } + } + // --------------------------------------- // Tools Tests // --------------------------------------- diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java index c2496e204..75d370410 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java @@ -299,6 +299,51 @@ void testListPromptsWithCursorAndMeta() { } + @Test + void testListSkillsWithCursorAndMeta() { + var transport = new TestMcpClientTransport(); + McpAsyncClient client = McpClient.async(transport).build(); + + McpSchema.ListSkillsResult result = client.listSkills("cursor-1", Map.of("customKey", "customValue")).block(); + + assertThat(result.skills()).extracting(McpSchema.Skill::uri).containsExactly("skill://test/SKILL.md"); + assertThat(transport.getCapturedRequest().cursor()).isEqualTo("cursor-1"); + assertThat(transport.getCapturedRequest().meta()).containsEntry("customKey", "customValue"); + } + + @Test + void testGetSkill() { + var transport = new TestMcpClientTransport(); + McpAsyncClient client = McpClient.async(transport).build(); + + McpSchema.GetSkillResult result = client.getSkill("skill://test/SKILL.md").block(); + + assertThat(result.skill().uri()).isEqualTo("skill://test/SKILL.md"); + assertThat(transport.getCapturedRequestMessage().method()).isEqualTo(McpSchema.METHOD_SKILLS_GET); + assertThat(JSON_MAPPER + .convertValue(transport.getCapturedRequestMessage().params(), McpSchema.GetSkillRequest.class) + .uri()).isEqualTo("skill://test/SKILL.md"); + } + + @Test + void testReadDirectoryWithCursorAndMeta() { + var transport = new TestMcpClientTransport(); + McpAsyncClient client = McpClient.async(transport).build(); + + McpSchema.ListResourcesResult result = client + .readDirectory("skill://test/templates", "cursor-1", Map.of("customKey", "customValue")) + .block(); + + assertThat(result.resources()).extracting(McpSchema.Resource::uri) + .containsExactly("skill://test/templates/example.md"); + assertThat(transport.getCapturedRequestMessage().method()).isEqualTo(McpSchema.METHOD_RESOURCES_DIRECTORY_READ); + McpSchema.ReadDirectoryRequest request = JSON_MAPPER + .convertValue(transport.getCapturedRequestMessage().params(), McpSchema.ReadDirectoryRequest.class); + assertThat(request.uri()).isEqualTo("skill://test/templates"); + assertThat(request.cursor()).isEqualTo("cursor-1"); + assertThat(request.meta()).containsEntry("customKey", "customValue"); + } + @Test void listResourcesStopsOnEmptyNextCursor() { var transport = new EmptyCursorTestMcpClientTransport(McpSchema.METHOD_RESOURCES_LIST); @@ -341,6 +386,8 @@ static class TestMcpClientTransport implements McpClientTransport { private McpSchema.PaginatedRequest capturedRequest = null; + private McpSchema.JSONRPCRequest capturedRequestMessage; + @Override public Mono connect(Function, Mono> handler) { return Mono.deferContextual(ctx -> { @@ -359,12 +406,14 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { if (!(message instanceof McpSchema.JSONRPCRequest request)) { return Mono.empty(); } + this.capturedRequestMessage = request; McpSchema.JSONRPCResponse response; if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder() .prompts(false) - .resources(false, false) + .resources(true, false) .tools(false) + .extensions(Map.of("io.modelcontextprotocol/skills", Map.of("directoryRead", true))) .build(); McpSchema.InitializeResult initResult = McpSchema.InitializeResult @@ -412,6 +461,26 @@ else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { McpSchema.ListToolsResult mockToolsResult = McpSchema.ListToolsResult.builder(List.of(addTool)).build(); response = McpSchema.JSONRPCResponse.result(request.id(), mockToolsResult); } + else if (McpSchema.METHOD_SKILLS_LIST.equals(request.method())) { + capturedRequest = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class); + McpSchema.Skill skill = new McpSchema.Skill("skill://test/SKILL.md", + McpSchema.SkillFrontmatter.of(Map.of("name", "test")), + McpSchema.SkillResources.manifest(List.of())); + response = McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.ListSkillsResult.builder(List.of(skill)).build()); + } + else if (McpSchema.METHOD_SKILLS_GET.equals(request.method())) { + McpSchema.Skill skill = new McpSchema.Skill("skill://test/SKILL.md", + McpSchema.SkillFrontmatter.of(Map.of("name", "test")), + McpSchema.SkillResources.dynamicResources()); + response = McpSchema.JSONRPCResponse.result(request.id(), new McpSchema.GetSkillResult(skill)); + } + else if (McpSchema.METHOD_RESOURCES_DIRECTORY_READ.equals(request.method())) { + McpSchema.Resource child = McpSchema.Resource.builder("skill://test/templates/example.md", "example.md") + .build(); + response = McpSchema.JSONRPCResponse.result(request.id(), + McpSchema.ListResourcesResult.builder(List.of(child)).build()); + } else { return Mono.empty(); } @@ -432,6 +501,10 @@ public McpSchema.PaginatedRequest getCapturedRequest() { return capturedRequest; } + public McpSchema.JSONRPCRequest getCapturedRequestMessage() { + return capturedRequestMessage; + } + } static class EmptyCursorTestMcpClientTransport implements McpClientTransport { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpStatelessSkillTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpStatelessSkillTests.java new file mode 100644 index 000000000..44d0813be --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpStatelessSkillTests.java @@ -0,0 +1,257 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class McpStatelessSkillTests { + + private static final String SKILL_URI = "skill://data-analysis/SKILL.md"; + + private static final String DIGEST = "sha256:57985f31c60e16fa467845108d7c9ed98b744cae72bbe87f69aa01775b6f0a13"; + + @Test + void rejectsMalformedManifestDigest() { + StepVerifier + .create(server().addSkill(skill(List.of(new McpSchema.SkillResource(SKILL_URI, "sha256:invalid", 1L))))) + .expectErrorSatisfies(error -> org.assertj.core.api.Assertions.assertThat(error) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("digest")) + .verify(); + } + + @Test + void rejectsManifestWithoutSkillFile() { + StepVerifier + .create(server().addSkill( + skill(List.of(new McpSchema.SkillResource("skill://data-analysis/reference.md", DIGEST, 1L))))) + .expectErrorSatisfies(error -> org.assertj.core.api.Assertions.assertThat(error) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SKILL.md")) + .verify(); + } + + @Test + void acceptsDynamicSkill() { + McpSchema.Skill skill = new McpSchema.Skill(SKILL_URI, + McpSchema.SkillFrontmatter.of(Map.of("name", "data-analysis", "description", "Analyze tabular data.")), + McpSchema.SkillResources.dynamicResources()); + + StepVerifier.create(server().addSkill(skill)).verifyComplete(); + } + + @Test + void acceptsSkillWithNestedRoot() { + String skillUri = "skill://acme/billing/refunds/SKILL.md"; + McpSchema.Skill skill = skill(skillUri, "refunds", List.of(new McpSchema.SkillResource(skillUri, DIGEST, 1L), + new McpSchema.SkillResource("skill://acme/billing/refunds/references/guide.md", DIGEST, 1L))); + + StepVerifier.create(server().addSkill(skill)).verifyComplete(); + } + + @Test + void registersNestedSkillIndependently() { + TestTransport transport = new TestTransport(); + McpStatelessAsyncServer server = server(transport, false); + String parentUri = "skill://acme/billing/refunds/SKILL.md"; + String nestedUri = "skill://acme/billing/refunds/regional/SKILL.md"; + McpSchema.Skill parentSkill = skill(parentUri, "refunds", + List.of(new McpSchema.SkillResource(parentUri, DIGEST, 1L), + new McpSchema.SkillResource(nestedUri, DIGEST, 1L))); + McpSchema.Skill nestedSkill = skill(nestedUri, "regional", + List.of(new McpSchema.SkillResource(nestedUri, DIGEST, 1L))); + + StepVerifier.create(server.addSkill(parentSkill)).verifyComplete(); + + McpSchema.JSONRPCResponse unregisteredResponse = request(transport, McpSchema.METHOD_SKILLS_GET, + Map.of("uri", nestedUri)); + assertThat(unregisteredResponse.error().code()).isEqualTo(McpSchema.ErrorCodes.INVALID_PARAMS); + + StepVerifier.create(server.addSkill(nestedSkill)).verifyComplete(); + + McpSchema.JSONRPCResponse listResponse = request(transport, McpSchema.METHOD_SKILLS_LIST, Map.of()); + assertThat(((McpSchema.ListSkillsResult) listResponse.result()).skills()).containsExactlyInAnyOrder(parentSkill, + nestedSkill); + + McpSchema.JSONRPCResponse nestedResponse = request(transport, McpSchema.METHOD_SKILLS_GET, + Map.of("uri", nestedUri)); + assertThat(nestedResponse.error()).isNull(); + assertThat(((McpSchema.GetSkillResult) nestedResponse.result()).skill()).isEqualTo(nestedSkill); + } + + @Test + void exposesRegisteredSkillsThroughSkillsEndpoints() { + TestTransport transport = new TestTransport(); + McpStatelessAsyncServer server = server(transport, false); + McpSchema.Skill skill = skill(List.of(new McpSchema.SkillResource(SKILL_URI, DIGEST, 1L))); + + StepVerifier.create(server.addSkill(skill)).verifyComplete(); + + McpSchema.JSONRPCResponse listResponse = request(transport, McpSchema.METHOD_SKILLS_LIST, Map.of()); + assertThat(listResponse.error()).isNull(); + assertThat(((McpSchema.ListSkillsResult) listResponse.result()).skills()).containsExactly(skill); + + McpSchema.JSONRPCResponse getResponse = request(transport, McpSchema.METHOD_SKILLS_GET, + Map.of("uri", SKILL_URI)); + assertThat(getResponse.error()).isNull(); + assertThat(((McpSchema.GetSkillResult) getResponse.result()).skill()).isEqualTo(skill); + + McpSchema.JSONRPCResponse unknownResponse = request(transport, McpSchema.METHOD_SKILLS_GET, + Map.of("uri", "skill://unknown/SKILL.md")); + assertThat(unknownResponse.error().code()).isEqualTo(McpSchema.ErrorCodes.INVALID_PARAMS); + assertThat(unknownResponse.error().message()).isEqualTo("Unknown skill URI"); + } + + @Test + void registersSkillsConfiguredAtBuildTime() { + TestTransport transport = new TestTransport(); + McpSchema.Skill skill = skill(List.of(new McpSchema.SkillResource(SKILL_URI, DIGEST, 1L))); + McpServer.async(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, false) + .extensions(Map.of("io.modelcontextprotocol/skills", Map.of())) + .build()) + .skills(skill) + .build(); + + McpSchema.JSONRPCResponse response = request(transport, McpSchema.METHOD_SKILLS_LIST, Map.of()); + assertThat(response.error()).isNull(); + assertThat(((McpSchema.ListSkillsResult) response.result()).skills()).containsExactly(skill); + } + + @Test + void exposesSkillResourceDirectoriesWhenDirectoryReadIsEnabled() { + TestTransport transport = new TestTransport(); + McpStatelessAsyncServer server = server(transport, true); + McpSchema.Skill skill = skill(List.of(new McpSchema.SkillResource(SKILL_URI, DIGEST, 1L), + new McpSchema.SkillResource("skill://data-analysis/references/guide.md", DIGEST, 1L))); + McpSchema.Resource skillFile = McpSchema.Resource.builder(SKILL_URI, "SKILL.md").build(); + McpSchema.Resource guide = McpSchema.Resource.builder("skill://data-analysis/references/guide.md", "guide.md") + .build(); + + StepVerifier.create(server.addSkill(skill)).verifyComplete(); + StepVerifier.create(server.addResource(new McpStatelessServerFeatures.AsyncResourceSpecification(skillFile, + (context, request) -> Mono.just(new McpSchema.ReadResourceResult(List.of()))))) + .verifyComplete(); + StepVerifier.create(server.addResource(new McpStatelessServerFeatures.AsyncResourceSpecification(guide, + (context, request) -> Mono.just(new McpSchema.ReadResourceResult(List.of()))))) + .verifyComplete(); + + McpSchema.JSONRPCResponse rootResponse = request(transport, McpSchema.METHOD_RESOURCES_DIRECTORY_READ, + Map.of("uri", "skill://data-analysis")); + assertThat(rootResponse.error()).isNull(); + assertThat(((McpSchema.ListResourcesResult) rootResponse.result()).resources()) + .extracting(McpSchema.Resource::uri) + .containsExactlyInAnyOrder(SKILL_URI, "skill://data-analysis/references"); + + McpSchema.JSONRPCResponse invalidResponse = request(transport, McpSchema.METHOD_RESOURCES_DIRECTORY_READ, + Map.of("uri", "skill://unknown")); + assertThat(invalidResponse.error().code()).isEqualTo(McpSchema.ErrorCodes.INVALID_PARAMS); + assertThat(invalidResponse.error().message()).isEqualTo("URI does not identify a directory resource"); + } + + @Test + void delegatesDirectoryReadsToConfiguredHandler() { + TestTransport transport = new TestTransport(); + McpStatelessAsyncServer server = McpServer.async(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, false) + .extensions(Map.of("io.modelcontextprotocol/skills", Map.of("directoryRead", true))) + .build()) + .directoryReadHandler((context, request) -> { + assertThat(request.uri()).isEqualTo("skill://data-analysis/generated"); + assertThat(request.cursor()).isEqualTo("page-1"); + return Mono.just(McpSchema.ListResourcesResult.builder( + List.of(McpSchema.Resource.builder("skill://data-analysis/generated/report.md", "report.md") + .mimeType("text/markdown") + .build())) + .nextCursor("page-2") + .build()); + }) + .build(); + + McpSchema.JSONRPCResponse response = request(transport, McpSchema.METHOD_RESOURCES_DIRECTORY_READ, + Map.of("uri", "skill://data-analysis/generated", "cursor", "page-1")); + + assertThat(response.error()).isNull(); + McpSchema.ListResourcesResult result = (McpSchema.ListResourcesResult) response.result(); + assertThat(result.nextCursor()).isEqualTo("page-2"); + assertThat(result.resources()).extracting(McpSchema.Resource::uri) + .containsExactly("skill://data-analysis/generated/report.md"); + } + + @Test + void requiresResourceCapabilitiesWhenSkillsAreEnabled() { + assertThatThrownBy(() -> McpServer.async(new TestTransport()) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .extensions(Map.of("io.modelcontextprotocol/skills", Map.of())) + .build()) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Skills extension requires resource capabilities"); + } + + private static McpStatelessAsyncServer server() { + return server(new TestTransport(), false); + } + + private static McpStatelessAsyncServer server(TestTransport transport, boolean directoryRead) { + return McpServer.async(transport) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, false) + .extensions(Map.of("io.modelcontextprotocol/skills", Map.of("directoryRead", directoryRead))) + .build()) + .build(); + } + + private static McpSchema.JSONRPCResponse request(TestTransport transport, String method, + Map params) { + return transport.mcpHandler + .handleRequest(McpTransportContext.EMPTY, + new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, "request-id", params)) + .block(); + } + + private static McpSchema.Skill skill(List resources) { + return skill(SKILL_URI, "data-analysis", resources); + } + + private static McpSchema.Skill skill(String uri, String name, List resources) { + return new McpSchema.Skill(uri, + McpSchema.SkillFrontmatter.of(Map.of("name", name, "description", "Analyze tabular data.")), + McpSchema.SkillResources.manifest(resources)); + } + + private static final class TestTransport implements McpStatelessServerTransport { + + private McpStatelessServerHandler mcpHandler; + + @Override + public void setMcpHandler(McpStatelessServerHandler mcpHandler) { + this.mcpHandler = mcpHandler; + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index ab9bc8643..1ee7a91fc 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -376,6 +376,28 @@ void testInitializeResult() throws Exception { {"protocolVersion":"2024-11-05","capabilities":{"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"test-server","version":"1.0.0"},"instructions":"Server initialized successfully"}""")); } + @Test + void serverCapabilitiesDeserializesWithoutExtensions() throws Exception { + McpSchema.ServerCapabilities capabilities = JSON_MAPPER.readValue("{}", McpSchema.ServerCapabilities.class); + + assertThat(capabilities.extensions()).isNull(); + } + + @Test + void serverCapabilitiesOmitsNullExtensions() throws Exception { + String json = JSON_MAPPER.writeValueAsString(McpSchema.ServerCapabilities.builder().build()); + + assertThatJson(json).isEqualTo(json("{}")); + } + + @Test + void serverCapabilitiesToleratesUnknownFieldsWithExtensionsAbsent() throws Exception { + McpSchema.ServerCapabilities capabilities = JSON_MAPPER.readValue(""" + {"futureCapability":true}""", McpSchema.ServerCapabilities.class); + + assertThat(capabilities.extensions()).isNull(); + } + // Resource Tests @Test @@ -671,6 +693,131 @@ void testListPromptsResult() throws Exception { {"prompts":[{"name":"prompt1","title":"First prompt","description":"First prompt","arguments":[{"name":"arg","title":"Argument","description":"An argument","required":true}]},{"name":"prompt2","title":"Second prompt","description":"Second prompt","arguments":[]}],"nextCursor":"next-cursor"}""")); } + @Test + void testSkillResults() throws Exception { + McpSchema.SkillResource resource = new McpSchema.SkillResource("skill://pdf/SKILL.md", "sha256:abc", 512L); + McpSchema.Skill skill = new McpSchema.Skill("skill://pdf/SKILL.md", + McpSchema.SkillFrontmatter.of(Map.of("name", "pdf", "description", "Process PDFs")), + McpSchema.SkillResources.manifest(List.of(resource))); + + McpSchema.ListSkillsResult listResult = McpSchema.ListSkillsResult.builder(List.of(skill)) + .nextCursor("next-cursor") + .build(); + String listJson = JSON_MAPPER.writeValueAsString(listResult); + assertThatJson(listJson).isEqualTo( + json(""" + {"skills":[{"uri":"skill://pdf/SKILL.md","frontmatter":{"name":"pdf","description":"Process PDFs"},"resources":[{"uri":"skill://pdf/SKILL.md","digest":"sha256:abc","size":512}]}],"nextCursor":"next-cursor"}""")); + + McpSchema.GetSkillResult getResult = JSON_MAPPER.readValue(""" + {"skill":{"uri":"skill://pdf/SKILL.md","frontmatter":{"name":"pdf"},"resources":"dynamic"}}""", + McpSchema.GetSkillResult.class); + assertThat(getResult.skill().resources().dynamic()).isTrue(); + assertThat(getResult.skill().resources().manifest()).isNull(); + assertThat(getResult.skill().frontmatter().name()).isEqualTo("pdf"); + } + + @Test + void testReadDirectoryRequest() throws Exception { + McpSchema.ReadDirectoryRequest request = new McpSchema.ReadDirectoryRequest("skill://pdf/templates", "cursor-1", + Map.of("progressToken", "token")); + + assertThatJson(JSON_MAPPER.writeValueAsString(request)).isEqualTo(json(""" + {"uri":"skill://pdf/templates","cursor":"cursor-1","_meta":{"progressToken":"token"}}""")); + } + + @Test + void skillSchemaRequiredConstructorsRejectNull() { + McpSchema.SkillResource resource = new McpSchema.SkillResource("skill://pdf/SKILL.md", "sha256:abc", 1L); + McpSchema.SkillFrontmatter frontmatter = McpSchema.SkillFrontmatter.of(Map.of()); + McpSchema.SkillResources resources = McpSchema.SkillResources.manifest(List.of(resource)); + + assertThatThrownBy(() -> new McpSchema.ReadDirectoryRequest(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.SkillResource(null, "sha256:abc", 1L)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.SkillResource("skill://pdf/SKILL.md", null, 1L)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.SkillResource("skill://pdf/SKILL.md", "sha256:abc", null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.SkillFrontmatter(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.Skill(null, frontmatter, resources)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.Skill("skill://pdf/SKILL.md", null, resources)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.Skill("skill://pdf/SKILL.md", frontmatter, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.ListSkillsResult(null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.GetSkillRequest(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new McpSchema.GetSkillResult(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void skillSchemaDeserializesMissingRequiredFieldsWithDefaults() throws Exception { + McpSchema.ReadDirectoryRequest directoryRequest = JSON_MAPPER.readValue("{}", + McpSchema.ReadDirectoryRequest.class); + McpSchema.SkillResource resourceWithoutUri = JSON_MAPPER.readValue(""" + {"digest":"sha256:abc","size":1}""", McpSchema.SkillResource.class); + McpSchema.SkillResource resourceWithoutDigest = JSON_MAPPER.readValue(""" + {"uri":"skill://pdf/SKILL.md","size":1}""", McpSchema.SkillResource.class); + McpSchema.SkillResource resourceWithoutSize = JSON_MAPPER.readValue(""" + {"uri":"skill://pdf/SKILL.md","digest":"sha256:abc"}""", McpSchema.SkillResource.class); + McpSchema.Skill skillWithoutUri = JSON_MAPPER.readValue(""" + {"frontmatter":{},"resources":[]}""", McpSchema.Skill.class); + McpSchema.Skill skillWithoutFrontmatter = JSON_MAPPER.readValue(""" + {"uri":"skill://pdf/SKILL.md","resources":[]}""", McpSchema.Skill.class); + McpSchema.Skill skillWithoutResources = JSON_MAPPER.readValue(""" + {"uri":"skill://pdf/SKILL.md","frontmatter":{}}""", McpSchema.Skill.class); + McpSchema.ListSkillsResult listResult = JSON_MAPPER.readValue("{}", McpSchema.ListSkillsResult.class); + McpSchema.GetSkillRequest getRequest = JSON_MAPPER.readValue("{}", McpSchema.GetSkillRequest.class); + McpSchema.GetSkillResult getResult = JSON_MAPPER.readValue("{}", McpSchema.GetSkillResult.class); + McpSchema.Skill nestedResourceSkill = JSON_MAPPER.readValue(""" + {"uri":"skill://pdf/SKILL.md","frontmatter":{},"resources":[{}]}""", McpSchema.Skill.class); + + assertThat(directoryRequest.uri()).isEmpty(); + assertThat(resourceWithoutUri.uri()).isEmpty(); + assertThat(resourceWithoutDigest.digest()).isEmpty(); + assertThat(resourceWithoutSize.size()).isZero(); + assertThat(skillWithoutUri.uri()).isEmpty(); + assertThat(skillWithoutFrontmatter.frontmatter().values()).isEmpty(); + assertThat(skillWithoutResources.resources().manifest()).isEmpty(); + assertThat(listResult.skills()).isEmpty(); + assertThat(getRequest.uri()).isEmpty(); + assertThat(getResult.skill().uri()).isEmpty(); + assertThat(nestedResourceSkill.resources().manifest()).singleElement().satisfies(nestedResource -> { + assertThat(nestedResource.uri()).isEmpty(); + assertThat(nestedResource.digest()).isEmpty(); + assertThat(nestedResource.size()).isZero(); + }); + } + + @Test + void skillSchemaToleratesUnknownFields() throws Exception { + assertThat(JSON_MAPPER.readValue(""" + {"uri":"skill://pdf","futureField":true}""", McpSchema.ReadDirectoryRequest.class).uri()) + .isEqualTo("skill://pdf"); + assertThat(JSON_MAPPER + .readValue(""" + {"uri":"skill://pdf/SKILL.md","digest":"sha256:abc","size":1,"futureField":true}""", + McpSchema.SkillResource.class) + .uri()).isEqualTo("skill://pdf/SKILL.md"); + assertThat(JSON_MAPPER + .readValue(""" + {"uri":"skill://pdf/SKILL.md","frontmatter":{},"resources":[],"futureField":true}""", + McpSchema.Skill.class) + .uri()).isEqualTo("skill://pdf/SKILL.md"); + assertThat(JSON_MAPPER.readValue(""" + {"skills":[],"futureField":true}""", McpSchema.ListSkillsResult.class).skills()).isEmpty(); + assertThat(JSON_MAPPER.readValue(""" + {"uri":"skill://pdf/SKILL.md","futureField":true}""", McpSchema.GetSkillRequest.class).uri()) + .isEqualTo("skill://pdf/SKILL.md"); + assertThat(JSON_MAPPER + .readValue(""" + {"skill":{"uri":"skill://pdf/SKILL.md","frontmatter":{},"resources":[]},"futureField":true}""", + McpSchema.GetSkillResult.class) + .skill() + .uri()).isEqualTo("skill://pdf/SKILL.md"); + } + @Test void testGetPromptRequest() throws Exception { Map arguments = new HashMap<>();