CtrlK
CommunityDocumentationLog inGet started
Tessl Logo

tessl/maven-com-embabel-agent--embabel-agent-mcpserver

Discover and Export available Agent(s) as MCP Servers

Overview
Eval results
Files

creating-publishers.mddocs/guides/

Creating Custom Publishers

Guide to creating custom tool, resource, and prompt publishers as Spring beans.

Publisher Types

Three publisher interfaces for extending MCP server:

  • McpExportToolCallbackPublisher: Export tools (mode-agnostic)
  • McpResourcePublisher / McpAsyncResourcePublisher: Expose resources
  • McpPromptPublisher / McpAsyncPromptPublisher: Define prompts

All publishers are Spring beans auto-discovered via component scanning.

Tool Publishers

Basic Tool Publisher

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()
}

Using McpToolExport

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()
}

Mode-Specific Tool Publishers

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"
}

Resource Publishers (Sync)

Static Resource Publisher

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()
}

Dynamic Resource Publisher

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 */)

Resource Publishers (Async)

Async Resource Publisher

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)

Prompt Publishers (Sync)

Using McpPromptFactory

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?
)

Prompt Publishers (Async)

Async Prompts

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
)

Combined Publishers

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>)

Conditional Publishers

Profile-Based

@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"
}

Property-Based

@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"
}

Best Practices

1. Single Responsibility

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 */ }

2. Lazy Evaluation

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 */)
    }
}

3. Error Handling

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"
}

4. Meaningful Info Strings

// 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"

5. Testing

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"))
    }
}

Troubleshooting

Publisher Not Discovered

Cause: Missing @Service annotation or not in scanned package

Solution: Add @Service and ensure package is scanned

@Service  // Required!
class MyPublisher : McpExportToolCallbackPublisher {
    // ...
}

Empty Results

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
    }

Wrong Mode

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 { /* ... */ }

Related Documentation

  • Publisher APIs - Complete API reference
  • Exporting Tools - Tool export strategies
  • Resources and Prompts - Resource/prompt creation
  • Execution Modes - Mode selection guide
tessl i tessl/maven-com-embabel-agent--embabel-agent-mcpserver@0.3.1

docs

index.md

tile.json