Discover and Export available Agent(s) as MCP Servers
Guide to creating custom tool, resource, and prompt publishers as Spring beans.
Three publisher interfaces for extending MCP server:
All publishers are Spring beans auto-discovered via component scanning.
import com.embabel.agent.mcpserver.McpExportToolCallbackPublisher
import org.springframework.ai.tool.ToolCallback
import org.springframework.stereotype.Service
@Service
class CalculatorPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks: List<ToolCallback>
get() = listOf(
createAddTool(),
createSubtractTool(),
createMultiplyTool()
)
override fun infoString(verbose: Boolean?, indent: Int): String {
return "CalculatorPublisher: ${toolCallbacks.size} tools"
}
private fun createAddTool(): ToolCallback {
// Create ToolCallback implementation
// (implementation depends on your tooling framework)
}
private fun createSubtractTool(): ToolCallback = TODO()
private fun createMultiplyTool(): ToolCallback = TODO()
}Leverage McpToolExport for easier tool creation:
import com.embabel.agent.mcpserver.McpToolExport
import com.embabel.agent.api.common.ToolObject
@Service
class ApiToolPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks: List<ToolCallback>
get() {
val toolObject = ToolObject(
objects = listOf(getUserTool(), createUserTool(), updateUserTool()),
namingStrategy = { "user_api_$it" }
)
return McpToolExport.fromToolObject(toolObject).toolCallbacks
}
override fun infoString(verbose: Boolean?, indent: Int) =
"ApiToolPublisher: ${toolCallbacks.size} tools"
private fun getUserTool(): Any = TODO("Create tool instance")
private fun createUserTool(): Any = TODO()
private fun updateUserTool(): Any = TODO()
}Activate only in specific modes:
// Sync mode only
@Service
@ConditionalOnProperty(
value = ["spring.ai.mcp.server.type"],
havingValue = "SYNC",
matchIfMissing = true
)
class SyncOnlyPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks: List<ToolCallback> = listOf(/* sync tools */)
override fun infoString(verbose: Boolean?, indent: Int) = "SyncOnlyPublisher"
}
// Async mode only
@Service
@ConditionalOnProperty(
value = ["spring.ai.mcp.server.type"],
havingValue = "ASYNC"
)
class AsyncOnlyPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks: List<ToolCallback> = listOf(/* async tools */)
override fun infoString(verbose: Boolean?, indent: Int) = "AsyncOnlyPublisher"
}Publish fixed content resources:
import com.embabel.agent.mcpserver.sync.McpResourcePublisher
import com.embabel.agent.mcpserver.sync.SyncResourceSpecificationFactory
import io.modelcontextprotocol.server.McpServerFeatures
import org.springframework.stereotype.Service
@Service
class DocumentationPublisher : McpResourcePublisher {
override fun resources(): List<McpServerFeatures.SyncResourceSpecification> {
return listOf(
SyncResourceSpecificationFactory.staticSyncResourceSpecification(
uri = "app://docs/api-reference",
name = "APIReference",
description = "API reference documentation",
content = loadApiDocs(),
mimeType = "text/markdown"
),
SyncResourceSpecificationFactory.staticSyncResourceSpecification(
uri = "app://docs/getting-started",
name = "GettingStarted",
description = "Getting started guide",
content = loadGettingStarted(),
mimeType = "text/markdown"
)
)
}
override fun infoString(verbose: Boolean?, indent: Int) =
"DocumentationPublisher: ${resources().size} resources"
private fun loadApiDocs(): String = """
# API Reference
## Endpoints
- GET /api/users
- POST /api/users
""".trimIndent()
private fun loadGettingStarted(): String = """
# Getting Started
Welcome to our API!
""".trimIndent()
}Load content dynamically:
import io.modelcontextprotocol.server.McpSyncServerExchange
@Service
class StatusPublisher : McpResourcePublisher {
override fun resources(): List<McpServerFeatures.SyncResourceSpecification> {
return listOf(
SyncResourceSpecificationFactory.syncResourceSpecification(
uri = "app://status/current",
name = "CurrentStatus",
description = "Current system status",
resourceLoader = { exchange: McpSyncServerExchange ->
val status = getCurrentStatus()
objectMapper.writeValueAsString(status)
},
mimeType = "application/json"
),
SyncResourceSpecificationFactory.syncResourceSpecification(
uri = "app://status/metrics",
name = "Metrics",
description = "System metrics",
resourceLoader = { exchange ->
getMetrics().toString()
},
mimeType = "application/json"
)
)
}
override fun infoString(verbose: Boolean?, indent: Int) =
"StatusPublisher: ${resources().size} resources"
private fun getCurrentStatus(): Status {
return Status(
healthy = true,
uptime = getUptimeMillis(),
activeUsers = getActiveUserCount()
)
}
private fun getMetrics(): Metrics = Metrics(/* ... */)
}
data class Status(val healthy: Boolean, val uptime: Long, val activeUsers: Int)
data class Metrics(/* metrics fields */)Non-blocking resource loading:
import com.embabel.agent.mcpserver.async.McpAsyncResourcePublisher
import io.modelcontextprotocol.server.McpServerFeatures
import io.modelcontextprotocol.spec.McpSchema
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service
@ConditionalOnProperty(
value = ["spring.ai.mcp.server.type"],
havingValue = "ASYNC"
)
class AsyncDataPublisher : McpAsyncResourcePublisher {
override fun resources(): List<McpServerFeatures.AsyncResourceSpecification> {
return listOf(
McpServerFeatures.AsyncResourceSpecification(
McpSchema.Resource(
"app://data/users",
"Users",
"User data from database",
"application/json",
null
)
) { exchange, request ->
loadUsersAsync()
.map { users ->
McpSchema.ReadResourceResult(
listOf(
McpSchema.TextResourceContents(
"app://data/users",
"application/json",
objectMapper.writeValueAsString(users)
)
)
)
}
}
)
}
override fun infoString(verbose: Boolean?, indent: Int) =
"AsyncDataPublisher: ${resources().size} resources"
private fun loadUsersAsync(): Mono<List<User>> {
// Non-blocking database query
return Mono.fromCallable { listOf(/* users */) }
.subscribeOn(Schedulers.boundedElastic())
}
}
data class User(val id: String, val name: String)import com.embabel.agent.mcpserver.sync.McpPromptPublisher
import com.embabel.agent.mcpserver.sync.McpPromptFactory
import com.embabel.common.core.types.Named
import com.embabel.common.core.types.Described
import com.fasterxml.jackson.annotation.JsonPropertyDescription
import io.modelcontextprotocol.server.McpServerFeatures
import org.springframework.stereotype.Service
@Service
class TaskPromptsPublisher : McpPromptPublisher {
private val factory = McpPromptFactory()
override fun prompts(): List<McpServerFeatures.SyncPromptSpecification> {
return listOf(
createTaskPrompt(),
updateTaskPrompt(),
searchTasksPrompt()
)
}
override fun infoString(verbose: Boolean?, indent: Int) =
"TaskPromptsPublisher: ${prompts().size} prompts"
private fun createTaskPrompt(): McpServerFeatures.SyncPromptSpecification {
val goal = object : Named, Described {
override val name = "createTask"
override val description = "Create a new task"
}
return factory.syncPromptSpecificationForType(
goal = goal,
inputType = CreateTaskInput::class.java
)
}
private fun updateTaskPrompt(): McpServerFeatures.SyncPromptSpecification {
val goal = object : Named, Described {
override val name = "updateTask"
override val description = "Update existing task"
}
return factory.syncPromptSpecificationForType(
goal = goal,
inputType = UpdateTaskInput::class.java
)
}
private fun searchTasksPrompt(): McpServerFeatures.SyncPromptSpecification {
val goal = object : Named, Described {
override val name = "searchTasks"
override val description = "Search for tasks"
}
return factory.syncPromptSpecificationForType(
goal = goal,
inputType = SearchTasksInput::class.java
)
}
}
data class CreateTaskInput(
@JsonPropertyDescription("Task title")
val title: String,
@JsonPropertyDescription("Task description")
val description: String,
@JsonPropertyDescription("Priority (1-5)")
val priority: Int
)
data class UpdateTaskInput(
@JsonPropertyDescription("Task ID")
val taskId: String,
@JsonPropertyDescription("New title (optional)")
val title: String?,
@JsonPropertyDescription("New description (optional)")
val description: String?
)
data class SearchTasksInput(
@JsonPropertyDescription("Search query")
val query: String,
@JsonPropertyDescription("Filter by status")
val status: String?
)import com.embabel.agent.mcpserver.async.McpAsyncPromptPublisher
import com.embabel.agent.mcpserver.async.McpAsyncPromptFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
@Service
@ConditionalOnProperty(
value = ["spring.ai.mcp.server.type"],
havingValue = "ASYNC"
)
class AsyncAnalysisPromptsPublisher : McpAsyncPromptPublisher {
private val factory = McpAsyncPromptFactory()
override fun prompts(): List<McpServerFeatures.AsyncPromptSpecification> {
val goal = object : Named, Described {
override val name = "analyzeData"
override val description = "Analyze data asynchronously"
}
return listOf(
factory.asyncPromptSpecificationForType(
goal = goal,
inputType = AnalysisInput::class.java
)
)
}
override fun infoString(verbose: Boolean?, indent: Int) =
"AsyncAnalysisPromptsPublisher: ${prompts().size} prompts"
}
data class AnalysisInput(
@JsonPropertyDescription("Data source")
val dataSource: String,
@JsonPropertyDescription("Analysis type")
val analysisType: String
)Implement multiple interfaces in one class:
@Service
class UnifiedPublisher : McpExportToolCallbackPublisher, McpResourcePublisher, McpPromptPublisher {
private val promptFactory = McpPromptFactory()
// Tools
override val toolCallbacks: List<ToolCallback>
get() = McpToolExport.fromToolObject(
ToolObject(
objects = listOf(processTool, analyzeTool),
namingStrategy = { "unified_$it" }
)
).toolCallbacks
// Resources
override fun resources(): List<McpServerFeatures.SyncResourceSpecification> {
return listOf(
SyncResourceSpecificationFactory.staticSyncResourceSpecification(
uri = "app://unified/help",
name = "Help",
description = "Help documentation",
content = "# Help\n\nUsage instructions...",
mimeType = "text/markdown"
)
)
}
// Prompts
override fun prompts(): List<McpServerFeatures.SyncPromptSpecification> {
val goal = object : Named, Described {
override val name = "unifiedAction"
override val description = "Execute unified action"
}
return listOf(
promptFactory.syncPromptSpecificationForType(
goal = goal,
inputType = UnifiedInput::class.java
)
)
}
override fun infoString(verbose: Boolean?, indent: Int): String {
return "UnifiedPublisher: ${toolCallbacks.size} tools, " +
"${resources().size} resources, ${prompts().size} prompts"
}
}
data class UnifiedInput(val action: String, val parameters: Map<String, Any>)@Service
@Profile("development")
class DevToolsPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks = listOf(/* debug tools */)
override fun infoString(verbose: Boolean?, indent: Int) = "DevToolsPublisher"
}
@Service
@Profile("production")
class ProdToolsPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks = listOf(/* production tools */)
override fun infoString(verbose: Boolean?, indent: Int) = "ProdToolsPublisher"
}@Service
@ConditionalOnProperty(name = "features.experimental", havingValue = "true")
class ExperimentalPublisher : McpExportToolCallbackPublisher {
override val toolCallbacks = listOf(/* experimental tools */)
override fun infoString(verbose: Boolean?, indent: Int) = "ExperimentalPublisher"
}
@Service
@ConditionalOnProperty(name = "features.analytics", havingValue = "true")
class AnalyticsPublisher : McpResourcePublisher {
override fun resources() = listOf(/* analytics resources */)
override fun infoString(verbose: Boolean?, indent: Int) = "AnalyticsPublisher"
}Create focused publishers:
// Good: Specific purpose
@Service
class UserApiToolsPublisher : McpExportToolCallbackPublisher { /* ... */ }
@Service
class UserApiResourcesPublisher : McpResourcePublisher { /* ... */ }
// Avoid: Too many responsibilities
@Service
class EverythingPublisher : McpExportToolCallbackPublisher,
McpResourcePublisher, McpPromptPublisher { /* too much */ }Compute lazily for expensive operations:
@Service
class ExpensivePublisher : McpResourcePublisher {
private val cachedResources by lazy {
// Expensive computation only on first access
computeResources()
}
override fun resources() = cachedResources
override fun infoString(verbose: Boolean?, indent: Int) =
"ExpensivePublisher: ${resources().size} resources"
private fun computeResources(): List<McpServerFeatures.SyncResourceSpecification> {
// Expensive operation
Thread.sleep(1000)
return listOf(/* resources */)
}
}Return empty lists on errors rather than throwing:
@Service
class SafePublisher : McpResourcePublisher {
override fun resources(): List<McpServerFeatures.SyncResourceSpecification> {
return try {
loadResourcesFromExternalSource()
} catch (e: Exception) {
logger.error("Failed to load resources", e)
emptyList() // Graceful degradation
}
}
override fun infoString(verbose: Boolean?, indent: Int) =
"SafePublisher: ${resources().size} resources"
}// Good: Descriptive
override fun infoString(verbose: Boolean?, indent: Int): String {
return if (verbose == true) {
"ApiToolsPublisher: ${toolCallbacks.size} tools " +
"(auth: 3, data: 5, admin: 2)"
} else {
"ApiToolsPublisher: ${toolCallbacks.size} tools"
}
}
// Adequate: Basic info
override fun infoString(verbose: Boolean?, indent: Int) =
"ApiToolsPublisher: ${toolCallbacks.size} tools"Test publishers in isolation:
class ApiToolsPublisherTest {
private val publisher = ApiToolsPublisher()
@Test
fun `should export correct number of tools`() {
val callbacks = publisher.toolCallbacks
assertEquals(10, callbacks.size)
}
@Test
fun `should use correct naming strategy`() {
val callbacks = publisher.toolCallbacks
assertTrue(callbacks.all { it.name.startsWith("api_") })
}
@Test
fun `should provide info string`() {
val info = publisher.infoString(verbose = false, indent = 0)
assertTrue(info.contains("ApiToolsPublisher"))
}
}Cause: Missing @Service annotation or not in scanned package
Solution: Add @Service and ensure package is scanned
@Service // Required!
class MyPublisher : McpExportToolCallbackPublisher {
// ...
}Cause: Publisher returns empty list
Solution: Verify logic and check for errors
override val toolCallbacks: List<ToolCallback>
get() {
val callbacks = createCallbacks()
println("DEBUG: Created ${callbacks.size} callbacks")
return callbacks
}Cause: Using sync publisher in async mode or vice versa
Solution: Use mode-appropriate interfaces and conditionals
// Sync mode
@Service
@ConditionalOnProperty(
value = ["spring.ai.mcp.server.type"],
havingValue = "SYNC",
matchIfMissing = true
)
class SyncPublisher : McpResourcePublisher { /* ... */ }
// Async mode
@Service
@ConditionalOnProperty(
value = ["spring.ai.mcp.server.type"],
havingValue = "ASYNC"
)
class AsyncPublisher : McpAsyncResourcePublisher { /* ... */ }