MCP Extensions Example
This example demonstrates how to build applications on top of hyperserve that expose their functionality through MCP tools and resources.
Overview
The example creates a simple blog application that exposes one tool per
operation plus a subscribable blog://posts/{id} resource template. Most
tools use mcp.NewTypedTool; search_posts intentionally uses the
lower-level builder so both shapes are visible.
- create_post - Create a blog post
- get_post - Fetch one blog post by ID
- list_posts - List blog posts
- delete_post - Delete a blog post
- search_posts - Search posts by keyword or tag
MCP Resources
- blog://posts/{id} - Read a concrete blog post by ID
- resources/subscribe - Subscribe to a concrete post URI over SSE or stdio
Key Concepts
1. Extension Builder Pattern
extension := mcp.NewExtension("blog").
WithDescription("Blog management tools").
WithTool(myTool).
Build()
type CreatePostArgs struct {
Title string `json:"title" validate:"required,max=200"`
Author string `json:"author" validate:"required"`
}
tool := mcp.NewTypedTool("create_post", "Create a new blog post.", store.Create)
tool := mcp.NewTool("search_posts").
WithDescription("Search posts").
WithParameter("query", "string", "Substring matched against title and content", false).
WithExecute(func(params map[string]any) (any, error) {
return search(params), nil
}).
Build()
4. Resource Template Pattern
type postResourceTemplate struct {
store *blog
}
func (t postResourceTemplate) URITemplate() string { return "blog://posts/{id}" }
func (t postResourceTemplate) Match(uri string) (map[string]string, bool) {
id, ok := strings.CutPrefix(uri, "blog://posts/")
if !ok || id == "" {
return nil, false
}
return map[string]string{"id": id}, true
}
func (t postResourceTemplate) Read(ctx context.Context, uri string, params map[string]string) (any, error) {
return t.store.Get(ctx, GetPostArgs{ID: params["id"]})
}
Running the Example
go run main.go
Using with Claude
After configuring Claude Desktop with your server, you can:
-
Content Management
- "Create a blog post about Go generics"
- "List all blog posts"
- "Show me posts by Alice"
-
Search and Discovery
- "Find posts tagged with 'golang'"
- "Search for posts about 'concurrency'"
Testing with curl
# List available tools
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}'
# Create a blog post
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "create_post",
"arguments": {
"title": "My New Post",
"content": "This is the content...",
"author": "Claude",
"tags": ["ai", "mcp"]
}
},
"id": 2
}'
# List resource templates
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "resources/templates/list",
"id": 3
}'
# Read a concrete post resource after creating a post
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "resources/read",
"params": {
"uri": "blog://posts/post-123"
},
"id": 4
}'
For live post invalidations, connect to /mcp with Accept: text/event-stream, then route a resources/subscribe request with the
returned clientId and bindingToken. The notification is
notifications/resources/updated; clients call resources/read to fetch the
latest post body.
# 1. Keep this open and copy clientId + bindingToken from the connection event.
curl -N -H "Accept: text/event-stream" http://localhost:8080/mcp
# 2. Subscribe to a concrete resource URI over the routed SSE session.
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "X-SSE-Client-ID: sse-..." \
-H "X-SSE-Binding: <bindingToken>" \
-d '{"jsonrpc":"2.0","method":"resources/subscribe","params":{"uri":"blog://posts/post-123"},"id":5}'
# 3. Later, cancel the subscription.
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "X-SSE-Client-ID: sse-..." \
-H "X-SSE-Binding: <bindingToken>" \
-d '{"jsonrpc":"2.0","method":"resources/unsubscribe","params":{"uri":"blog://posts/post-123"},"id":6}'
For stdio MCP servers, use the same JSON-RPC payloads as newline-delimited
stdin. Responses and notifications/resources/updated are written to stdout:
{"jsonrpc":"2.0","method":"resources/templates/list","id":1}
{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"blog://posts/post-123"},"id":2}
{"jsonrpc":"2.0","method":"resources/subscribe","params":{"uri":"blog://posts/post-123"},"id":3}
{"jsonrpc":"2.0","method":"resources/unsubscribe","params":{"uri":"blog://posts/post-123"},"id":4}
Building Your Own Extensions
Step 1: Define Your Domain
Identify the tools and resources that make sense for your application:
- Tools: Actions users can perform
- Resources: Data users can access
Tools should:
- Have clear, action-oriented names
- Include comprehensive parameter schemas
- Return structured, predictable responses
- Handle errors gracefully
Step 3: Create Resources
Resources should:
- Use descriptive URIs (e.g.,
app://type/name)
- Return consistent data structures
- Be read-only (resources don't modify state)
- Use templates for parameterized families
- Opt into caching only when stale reads are acceptable
Step 4: Package as Extension
Group related tools and resources into extensions:
- Logical grouping (e.g., "blog", "auth", "analytics")
- Shared configuration
- Clear documentation
Best Practices
- Clear Naming - Use descriptive names for tools and resources
- Rich Schemas - Provide detailed parameter descriptions
- Error Handling - Return helpful error messages
- Idempotency - Make tools idempotent when possible
- Security - Validate all inputs, sanitize outputs
- Documentation - Include examples in descriptions
Advanced Patterns
type StatefulTool struct {
db Database
cache Cache
}
func (t *StatefulTool) Execute(params map[string]any) (any, error) {
// Access t.db, t.cache, etc.
}
Context-Aware Resources
type UserResource struct {
getCurrentUser func() *User
}
func (r *UserResource) Read() (any, error) {
user := r.getCurrentUser()
// Return user-specific data
}
Async Operations
func (t *JobTool) Execute(params map[string]any) (any, error) {
jobID := startBackgroundJob(params)
return map[string]any{
"job_id": jobID,
"status": "started",
"check_status_with": "job_status tool",
}, nil
}