Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bb25d6e7f | |||
| b8fc0902d7 | |||
| 524e16296b | |||
| d91812b476 | |||
| 76bfeae456 | |||
| f07a49e0d9 | |||
| 6247b1d065 | |||
| c800dd3729 | |||
| 870b540922 | |||
| fad2fd7af4 | |||
| 19892f2c67 | |||
| d2036b880d | |||
| cad0ff1e49 | |||
| ffe43e0e6d | |||
| ec9473f86f | |||
| 4c673f61b1 | |||
| bed94bfa95 | |||
| d02ff2ecfa | |||
| 6d9645adce | |||
| beeaad748e | |||
| 076b7c37be | |||
| ab6f027741 | |||
| 14ec9b955f | |||
| a789c9e94b | |||
| e110ccb5ff | |||
| e3337efd81 | |||
| 13d1116dee | |||
| 1db7903010 | |||
| 5e1042e5ae | |||
| 0f6453488e |
+105
@@ -0,0 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Go Micro are documented here.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/). Go Micro uses
|
||||
calendar-based versions (YYYY.MM) for the AI-native era.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Agent platform showcase** — full platform example (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog), demonstrating how existing microservices become agent-accessible with zero code changes (`examples/mcp/platform/`).
|
||||
- **Blog post: "Your Microservices Are Already an AI Platform"** — walkthrough of agent-service interaction patterns using real-world services (`internal/website/blog/7.md`).
|
||||
- **Circuit breakers for MCP gateway** — per-tool circuit breakers protect downstream services from cascading failures. Configurable max failures, open-state timeout, and half-open probing. Available via `Options.CircuitBreaker` and `--circuit-breaker` CLI flag (`gateway/mcp/circuitbreaker.go`).
|
||||
- **Helm chart for MCP gateway** — official Helm chart at `deploy/helm/mcp-gateway/` with Deployment, Service, ServiceAccount, HPA, and Ingress templates. Supports Consul/etcd/mDNS registries, JWT auth, rate limiting, audit logging, per-tool scopes, TLS ingress, and auto-scaling.
|
||||
- **MCP gateway benchmarks** — comprehensive benchmark suite for tool listing, lookup, auth, rate limiting, and JSON serialization (`gateway/mcp/benchmark_test.go`)
|
||||
- **Workflow example** — cross-service orchestration demo with Inventory, Orders, and Notifications services showing agents chaining multi-step workflows from natural language (`examples/mcp/workflow/`)
|
||||
- **Docker Compose deployment** — production-like setup with Consul registry, standalone MCP gateway, and Jaeger tracing in one `docker-compose up` (`examples/deployment/`)
|
||||
|
||||
---
|
||||
|
||||
## [2026.03] - March 2026
|
||||
|
||||
### Added
|
||||
|
||||
#### Developer Experience
|
||||
- **`micro new` MCP templates** — `micro new myservice` generates MCP-enabled services with doc comments, `@example` tags, and `WithMCP()` wired in. Use `--no-mcp` to opt out.
|
||||
- **`micro.New("name")` unified API** — single way to create services: `micro.New("greeter")` or `micro.New("greeter", micro.Address(":8080"))`. Replaces `micro.NewService()` + `service.New()` dual API.
|
||||
- **`service.Handle()` simplified registration** — register handlers with `service.Handle(new(Greeter))` instead of manual `server.NewHandler` + `server.Handle`.
|
||||
- **`micro.NewGroup()` modular monoliths** — run multiple services in one binary with shared lifecycle: `micro.NewGroup(users, orders).Run()`.
|
||||
- **`mcp.WithMCP()` one-liner** — add MCP to any service with a single option: `micro.New("name", mcp.WithMCP(":3001"))`.
|
||||
- **CRUD example** — contact book service with 6 operations, rich agent docs, and validation patterns (`examples/mcp/crud/`).
|
||||
|
||||
#### MCP Gateway
|
||||
- **WebSocket transport** — bidirectional JSON-RPC 2.0 streaming over WebSocket for real-time agent communication (`gateway/mcp/websocket.go`).
|
||||
- **OpenTelemetry integration** — full span instrumentation across HTTP, stdio, and WebSocket transports with W3C trace context propagation (`gateway/mcp/otel.go`).
|
||||
- **Standalone gateway binary** — `micro-mcp-gateway` with Docker support for running the MCP gateway independently of services.
|
||||
- **Per-tool auth scopes** — service-level (`server.WithEndpointScopes()`) and gateway-level (`Options.Scopes`) scope enforcement with bearer token auth.
|
||||
- **Rate limiting** — per-tool token bucket rate limiting (`Options.RateLimit`).
|
||||
- **Audit logging** — immutable audit records per tool call with trace ID, account, scopes, duration, and errors (`Options.AuditFunc`).
|
||||
|
||||
#### AI Model Package
|
||||
- **`model.Model` interface** — unified AI provider abstraction with `Generate()` and `Stream()` methods.
|
||||
- **Anthropic Claude provider** — `model/anthropic` with tool execution and auto-calling.
|
||||
- **OpenAI GPT provider** — `model/openai` with provider auto-detection from base URL.
|
||||
|
||||
#### Agent SDKs
|
||||
- **LangChain SDK** — `contrib/langchain-go-micro/` Python package with auto-discovery, tool generation, and multi-agent workflow examples.
|
||||
- **LlamaIndex SDK** — `contrib/go-micro-llamaindex/` Python package with RAG integration examples.
|
||||
|
||||
#### Documentation
|
||||
- **AI-native services guide** — building services for AI agents from scratch
|
||||
- **MCP security guide** — auth, scopes, and audit logging
|
||||
- **Tool descriptions guide** — writing doc comments that improve agent performance
|
||||
- **Agent patterns guide** — architecture patterns for agent integration
|
||||
- **Error handling guide** — writing agent-friendly error responses with typed errors
|
||||
- **Troubleshooting guide** — common MCP issues and solutions
|
||||
- **Migration guide** — add MCP to existing services in 5 minutes
|
||||
|
||||
#### CLI
|
||||
- **`micro mcp serve`** — start MCP server (stdio for Claude Code, HTTP for web agents)
|
||||
- **`micro mcp list`** — list available tools (human-readable or JSON)
|
||||
- **`micro mcp test`** — test tools with JSON input
|
||||
- **`micro mcp docs`** — generate tool documentation
|
||||
- **`micro mcp export`** — export to LangChain, OpenAPI, or JSON formats
|
||||
|
||||
#### Agent Playground
|
||||
- **Chat-focused UI** — redesigned playground with collapsible tool calls, real-time status, and thinking indicators
|
||||
- **Provider settings** — configurable OpenAI/Anthropic provider, model, and API key
|
||||
|
||||
### Changed
|
||||
- Service interface moved to `service.Service` with `micro.Service` as a type alias for backward compatibility.
|
||||
- `service.New()` returns `service.Service` interface (was `*ServiceImpl`).
|
||||
- `service.NewGroup()` accepts `service.Service` interface (was `*ServiceImpl`).
|
||||
- `go.mod` template in `micro new` updated to Go 1.22.
|
||||
|
||||
### Fixed
|
||||
- Handler `Handle()` method accepts variadic `server.HandlerOption` for scopes and metadata.
|
||||
- Store initialization uses service name as table automatically.
|
||||
- Service `Stop()` properly aggregates errors from lifecycle hooks.
|
||||
|
||||
---
|
||||
|
||||
## [2026.02] - February 2026
|
||||
|
||||
### Added
|
||||
- **MCP gateway library** — `gateway/mcp/` with HTTP/SSE and stdio transports, service discovery, tool generation, and JSON schema generation from Go types (2,500+ lines).
|
||||
- **CLI integration** — `micro run --mcp-address` flag to start MCP alongside services.
|
||||
- **Documentation extraction** — auto-extract tool descriptions from Go doc comments with `@example` tag and struct tag parsing.
|
||||
- **Blog post** — "Making Microservices AI-Native with MCP"
|
||||
- **MCP examples** — `examples/mcp/hello/` and `examples/mcp/documented/`
|
||||
|
||||
---
|
||||
|
||||
## [2026.01] - January 2026
|
||||
|
||||
### Added
|
||||
- **`micro deploy`** — deploy services to any Linux server via SSH + systemd with `micro deploy user@server`.
|
||||
- **`micro build`** — build Go binaries and Docker images with `micro build --docker`.
|
||||
- **Blog post** — "Introducing micro deploy"
|
||||
|
||||
---
|
||||
|
||||
_For earlier changes, see the [git log](https://github.com/micro/go-micro/commits/master)._
|
||||
@@ -0,0 +1,145 @@
|
||||
# CLAUDE.md - Go Micro Project Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
Go Micro is a framework for distributed systems development in Go. It provides pluggable abstractions for service discovery, RPC, pub/sub, config, auth, storage, and more.
|
||||
|
||||
The framework is evolving into an **AI-native platform** where every microservice is automatically accessible to AI agents via the Model Context Protocol (MCP).
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run tests for a specific package
|
||||
go test ./gateway/mcp/...
|
||||
go test ./ai/...
|
||||
go test ./model/...
|
||||
|
||||
# Lint
|
||||
make lint
|
||||
|
||||
# Format
|
||||
make fmt
|
||||
|
||||
# Build CLI
|
||||
go build -o micro ./cmd/micro
|
||||
|
||||
# Run locally with hot reload
|
||||
micro run
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
go-micro/
|
||||
├── ai/ # AI model providers (Anthropic, OpenAI)
|
||||
├── auth/ # Authentication (JWT, no-op)
|
||||
├── broker/ # Message broker (NATS, RabbitMQ)
|
||||
├── cache/ # Caching (Redis)
|
||||
├── client/ # RPC client (gRPC)
|
||||
├── cmd/micro/ # CLI tool (run, deploy, mcp, build, server)
|
||||
├── codec/ # Message codecs (JSON, Proto)
|
||||
├── config/ # Dynamic config (env, file, etcd, NATS)
|
||||
├── errors/ # Error handling
|
||||
├── events/ # Event system (NATS JetStream)
|
||||
├── gateway/
|
||||
│ ├── api/ # REST API gateway
|
||||
│ └── mcp/ # MCP gateway (core AI integration)
|
||||
│ └── deploy/ # Helm charts for MCP gateway
|
||||
├── health/ # Health checking
|
||||
├── logger/ # Logging
|
||||
├── metadata/ # Context metadata
|
||||
├── model/ # Typed data models (CRUD, queries, schemas)
|
||||
├── registry/ # Service discovery (mDNS, Consul, etcd)
|
||||
├── selector/ # Client-side load balancing
|
||||
├── server/ # RPC server
|
||||
├── service/ # Service interface + profiles
|
||||
├── store/ # Data persistence (Postgres, NATS KV)
|
||||
├── transport/ # Network transport
|
||||
├── wrapper/ # Middleware (auth, trace, metrics)
|
||||
├── examples/ # Working examples
|
||||
└── internal/ # Non-public: docs, utils, test harness
|
||||
```
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
- **Plugin architecture**: All abstractions use Go interfaces. Defaults work out of the box, everything is swappable.
|
||||
- **Progressive complexity**: Zero-config for development, full control for production.
|
||||
- **AI-native by default**: Every service is automatically an MCP tool. No extra code needed.
|
||||
- **In-repo plugins**: Plugins live in the main repo to avoid version compatibility issues.
|
||||
- **Reflection-based registration**: Handlers are registered via reflection for minimal boilerplate.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Standard Go conventions (gofmt, golint)
|
||||
- Functional options pattern for configuration (`WithX()` functions)
|
||||
- Interface-first design: define the interface, then implement
|
||||
- Tests alongside code (not in separate test directories)
|
||||
- Commit messages: imperative mood, concise summary line
|
||||
|
||||
## Current Focus & Priorities (March 2026)
|
||||
|
||||
### Status
|
||||
- **Q1 2026 (MCP Foundation):** COMPLETE
|
||||
- **Q2 2026 (Agent DX):** COMPLETE (100%)
|
||||
- **Q3 2026 (Production):** 50% complete (ahead of schedule)
|
||||
|
||||
### Priority 1: Agent Showcase & Examples
|
||||
Build compelling demos showing agents interacting with go-micro services in realistic scenarios.
|
||||
|
||||
### Priority 2: Additional Protocol Support
|
||||
- gRPC reflection-based MCP
|
||||
- HTTP/3 support
|
||||
|
||||
### Priority 3: Kubernetes & Deployment
|
||||
- Helm Charts for MCP gateway
|
||||
- Kubernetes Operator with CRDs
|
||||
|
||||
### Recently Completed
|
||||
- **`micro new` MCP Templates** - Scaffolds MCP-enabled services with doc comments, `@example` tags, `WithMCP()`. `--no-mcp` to opt out.
|
||||
- **CRUD Example** - Contact book service with 6 operations, rich agent docs (`examples/mcp/crud/`)
|
||||
- **Migration Guide** - "Add MCP to Existing Services" guide with 3 approaches
|
||||
- **Troubleshooting Guide** - Common MCP issues and solutions
|
||||
- **Error Handling Guide** - Patterns for agent-friendly error responses
|
||||
- **Documentation Guides** - Six guides: AI-native services, MCP security, tool descriptions, agent patterns, error handling, troubleshooting
|
||||
- **WithMCP Option** - One-line MCP setup (`gateway/mcp/option.go`)
|
||||
- **Agent Playground Redesign** - Chat-focused UI with collapsible tool calls
|
||||
- **Standalone Gateway Binary** - `micro-mcp-gateway` with Docker support
|
||||
- **WebSocket Transport** - Bidirectional JSON-RPC 2.0 streaming (`gateway/mcp/websocket.go`)
|
||||
- **OpenTelemetry Integration** - Full span instrumentation with W3C trace context (`gateway/mcp/otel.go`)
|
||||
- **LlamaIndex SDK** - Python package with RAG examples (`contrib/go-micro-llamaindex/`)
|
||||
|
||||
## Key Files
|
||||
|
||||
| Purpose | File |
|
||||
|---------|------|
|
||||
| MCP Gateway | `gateway/mcp/mcp.go` |
|
||||
| MCP Docs | `gateway/mcp/DOCUMENTATION.md` |
|
||||
| AI Interface | `ai/model.go` |
|
||||
| Model Layer | `model/model.go` |
|
||||
| CLI Entry | `cmd/micro/main.go` |
|
||||
| MCP CLI | `cmd/micro/mcp/` |
|
||||
| Server (run/server) | `cmd/micro/server/server.go` |
|
||||
| Roadmap | `internal/docs/ROADMAP_2026.md` |
|
||||
| Status | `internal/docs/CURRENT_STATUS_SUMMARY.md` |
|
||||
| Changelog | `CHANGELOG.md` |
|
||||
| Docs Site | `internal/website/docs/` |
|
||||
|
||||
## Roadmap & Status Documents
|
||||
|
||||
- **[ROADMAP.md](ROADMAP.md)** - General framework roadmap
|
||||
- **[internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md)** - AI-native era roadmap with business model
|
||||
- **[internal/docs/CURRENT_STATUS_SUMMARY.md](internal/docs/CURRENT_STATUS_SUMMARY.md)** - Quick status overview
|
||||
- **[internal/docs/PROJECT_STATUS_2026.md](internal/docs/PROJECT_STATUS_2026.md)** - Detailed technical status
|
||||
- **[internal/docs/IMPLEMENTATION_SUMMARY.md](internal/docs/IMPLEMENTATION_SUMMARY.md)** - Implementation notes
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** - What changed and when
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. Key points:
|
||||
- Open an issue before large changes
|
||||
- Include tests for new features
|
||||
- Run `make test` and `make lint` before submitting
|
||||
- Follow commit message format: `type: description` (e.g., `feat: add WebSocket transport`)
|
||||
@@ -1,282 +0,0 @@
|
||||
# Go Micro - Current Status Summary
|
||||
**Updated:** February 11, 2026
|
||||
|
||||
## 🎯 Executive Summary
|
||||
|
||||
**Go Micro's MCP integration is 3-4 months ahead of schedule**, with Q1 2026 goals complete and significant Q2/Q3 2026 features already delivered.
|
||||
|
||||
### Quick Status
|
||||
- ✅ **Q1 2026 (MCP Foundation):** 100% COMPLETE
|
||||
- 🟢 **Q2 2026 (Agent DX):** 60% COMPLETE (ahead of schedule)
|
||||
- 🟢 **Q3 2026 (Production):** 40% COMPLETE (ahead of schedule)
|
||||
- 🟡 **Q4 2026 (Ecosystem):** 0% COMPLETE (on track)
|
||||
|
||||
---
|
||||
|
||||
## 📊 What's Been Built
|
||||
|
||||
### ✅ Core MCP Integration (Q1 - COMPLETE)
|
||||
- **MCP Gateway Library** (`gateway/mcp/`) - 2,083 lines
|
||||
- HTTP/SSE transport
|
||||
- Stdio JSON-RPC 2.0 transport
|
||||
- Service discovery & tool generation
|
||||
- Schema generation from Go types
|
||||
|
||||
- **CLI Commands** (`micro mcp`)
|
||||
- `micro mcp serve` - Start MCP server (stdio or HTTP)
|
||||
- `micro mcp list` - List available tools
|
||||
- `micro mcp test` - Test tools (placeholder)
|
||||
|
||||
- **Documentation**
|
||||
- Complete API documentation
|
||||
- 2 working examples (hello, documented)
|
||||
- Blog post: "Making Microservices AI-Native with MCP"
|
||||
|
||||
### ✅ Advanced Features (Q2/Q3 - DELIVERED EARLY)
|
||||
|
||||
#### 🔒 Security & Auth
|
||||
- **Per-Tool Scopes**
|
||||
- Service-level: `server.WithEndpointScopes("Blog.Create", "blog:write")`
|
||||
- Gateway-level: `Options.Scopes` map for overrides
|
||||
- Bearer token authentication
|
||||
- Scope enforcement before RPC execution
|
||||
|
||||
#### 📊 Observability
|
||||
- **Tracing**
|
||||
- UUID trace IDs per tool call
|
||||
- Metadata propagation (`Mcp-Trace-Id`, `Mcp-Tool-Name`, `Mcp-Account-Id`)
|
||||
- Full call chain tracking
|
||||
|
||||
- **Audit Logging**
|
||||
- Immutable audit records per tool call
|
||||
- Captures: tool, account, scopes, allowed/denied, duration, errors
|
||||
- Callback function: `Options.AuditFunc`
|
||||
|
||||
#### 🚦 Rate Limiting
|
||||
- Per-tool rate limiters
|
||||
- Configurable requests/second and burst
|
||||
- Token bucket algorithm
|
||||
|
||||
#### 📝 Documentation Extraction
|
||||
- Auto-extract from Go doc comments
|
||||
- `@example` tag support for JSON examples
|
||||
- Struct tag parsing for parameter descriptions
|
||||
- Manual override via `WithEndpointDocs()`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 What Works Today
|
||||
|
||||
### For Claude Code Users
|
||||
```bash
|
||||
# Start MCP server for Claude Code
|
||||
micro mcp serve
|
||||
|
||||
# Add to ~/.claude/claude_desktop_config.json:
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-services": {
|
||||
"command": "micro",
|
||||
"args": ["mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### For Library Users
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService(micro.Name("myservice"))
|
||||
service.Init()
|
||||
|
||||
// Add MCP gateway (3 lines!)
|
||||
go mcp.ListenAndServe(":3000", mcp.Options{
|
||||
Registry: service.Options().Registry,
|
||||
Auth: authProvider, // Optional: auth.Auth
|
||||
Scopes: map[string][]string{ // Optional: per-tool scopes
|
||||
"myservice.Handler.Create": {"write"},
|
||||
},
|
||||
RateLimit: &mcp.RateLimitConfig{ // Optional
|
||||
RequestsPerSecond: 10,
|
||||
Burst: 20,
|
||||
},
|
||||
AuditFunc: func(r mcp.AuditRecord) { // Optional
|
||||
log.Printf("[audit] %+v", r)
|
||||
},
|
||||
})
|
||||
|
||||
service.Run()
|
||||
}
|
||||
```
|
||||
|
||||
### For Service Developers
|
||||
```go
|
||||
// Just add Go comments - docs extracted automatically!
|
||||
|
||||
// GetUser retrieves a user by ID. Returns full profile with email and preferences.
|
||||
//
|
||||
// @example {"id": "user-123"}
|
||||
func (s *UserService) GetUser(ctx context.Context, req *GetUserRequest, rsp *GetUserResponse) error {
|
||||
// implementation
|
||||
}
|
||||
|
||||
// Register with scopes
|
||||
handler := service.Server().NewHandler(
|
||||
new(UserService),
|
||||
server.WithEndpointScopes("UserService.Delete", "users:admin"),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Test Coverage
|
||||
|
||||
**568 lines** of comprehensive tests covering:
|
||||
- ✅ Scope validation & enforcement
|
||||
- ✅ Auth provider integration
|
||||
- ✅ Trace ID generation & propagation
|
||||
- ✅ Audit record creation
|
||||
- ✅ Rate limiting
|
||||
- ✅ HTTP & Stdio transports
|
||||
- ✅ Tool discovery & schema generation
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What's Next (Recommended Priorities)
|
||||
|
||||
### Immediate (Next 2 Weeks)
|
||||
1. **Complete `micro mcp test` command** (~1 day)
|
||||
- Implement actual tool testing with JSON input/output
|
||||
|
||||
2. **LangChain SDK** (~1 week)
|
||||
- Python package: `go-micro-langchain`
|
||||
- Auto-generate LangChain tools from registry
|
||||
- Example multi-agent workflow
|
||||
- **Impact:** Largest agent framework integration
|
||||
|
||||
3. **Interactive Playground** (~1 week)
|
||||
- Web UI for testing services with AI
|
||||
- Real-time tool call visualization
|
||||
- **Impact:** Critical for demos and sales
|
||||
|
||||
### Short-Term (Next Month)
|
||||
4. **WebSocket Transport** (~3 days)
|
||||
- Bidirectional streaming for long-running operations
|
||||
|
||||
5. **LlamaIndex SDK** (~1 week)
|
||||
- Python package for RAG integration
|
||||
|
||||
6. **Case Studies** (ongoing)
|
||||
- Document real-world usage
|
||||
|
||||
---
|
||||
|
||||
## 📊 By The Numbers
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Production Code** | 2,083 lines |
|
||||
| **Test Code** | 568 lines |
|
||||
| **Documentation Files** | 4+ |
|
||||
| **Working Examples** | 2 |
|
||||
| **CLI Commands** | 3 |
|
||||
| **Transports** | 2 (HTTP/SSE, Stdio) |
|
||||
| **Q1 Completion** | 100% |
|
||||
| **Ahead of Schedule** | 3-4 months |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Where We Are on the Roadmap
|
||||
|
||||
### Q1 2026: MCP Foundation
|
||||
**Status:** ✅ COMPLETE (100%)
|
||||
- All 6 planned deliverables complete
|
||||
- Production-ready implementation
|
||||
- Comprehensive documentation
|
||||
|
||||
### Q2 2026: Agent Developer Experience
|
||||
**Status:** 🟢 IN PROGRESS (60% complete)
|
||||
|
||||
**COMPLETED (ahead of schedule):**
|
||||
- ✅ Stdio transport for Claude Code
|
||||
- ✅ `micro mcp serve` and `list` commands
|
||||
- ✅ Tool descriptions from comments
|
||||
- ✅ `@example` tag support
|
||||
- ✅ Schema generation from struct tags
|
||||
- ✅ HTTP/SSE with auth
|
||||
|
||||
**NOT YET STARTED:**
|
||||
- ❌ `micro mcp test` (full implementation)
|
||||
- ❌ `micro mcp docs` and `export` commands
|
||||
- ❌ Agent SDKs (LangChain, LlamaIndex, AutoGPT)
|
||||
- ❌ Interactive Agent Playground
|
||||
- ❌ Multi-protocol (WebSocket, gRPC, HTTP/3)
|
||||
|
||||
### Q3 2026: Production & Scale
|
||||
**Status:** 🟢 IN PROGRESS (40% complete)
|
||||
|
||||
**COMPLETED (ahead of schedule):**
|
||||
- ✅ Per-tool authentication & scopes
|
||||
- ✅ Agent call tracing
|
||||
- ✅ Rate limiting
|
||||
- ✅ Audit logging
|
||||
- ✅ Bearer token auth
|
||||
|
||||
**NOT YET STARTED:**
|
||||
- ❌ Standalone MCP Gateway binary
|
||||
- ❌ Kubernetes Operator
|
||||
- ❌ Helm Charts
|
||||
- ❌ OpenTelemetry integration
|
||||
- ❌ Full observability dashboards
|
||||
|
||||
### Q4 2026: Ecosystem & Monetization
|
||||
**Status:** 🟡 PLANNING (0% complete)
|
||||
- All features planned for Q4 2026
|
||||
- On track to start in Q4
|
||||
|
||||
---
|
||||
|
||||
## 📖 Key Documents
|
||||
|
||||
1. **[PROJECT_STATUS_2026.md](./PROJECT_STATUS_2026.md)** - Comprehensive 20-page status report
|
||||
2. **[ROADMAP_2026.md](./ROADMAP_2026.md)** - Updated roadmap with completion markers
|
||||
3. **[/gateway/mcp/DOCUMENTATION.md](./gateway/mcp/DOCUMENTATION.md)** - Complete MCP documentation
|
||||
4. **[/examples/mcp/README.md](./examples/mcp/README.md)** - Examples and usage guide
|
||||
5. **[/internal/website/blog/2.md](./internal/website/blog/2.md)** - Launch blog post
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Key Achievements
|
||||
|
||||
1. **✅ Production-Ready in Q1** - Ahead of schedule
|
||||
2. **✅ Security-First** - Auth, scopes, audit from day one
|
||||
3. **✅ Developer-Friendly** - 3 lines of code to enable MCP
|
||||
4. **✅ Claude Code Ready** - Works with Anthropic's flagship IDE
|
||||
5. **✅ Comprehensive Testing** - 90%+ test coverage
|
||||
6. **✅ Well-Documented** - Multiple docs + examples + blog post
|
||||
|
||||
---
|
||||
|
||||
## 💡 Bottom Line
|
||||
|
||||
**Go Micro is production-ready for AI agent integration TODAY.**
|
||||
|
||||
The Q1 2026 foundation is solid, with advanced Q2/Q3 features already delivered. The framework is:
|
||||
- ✅ Ready for production use
|
||||
- ✅ Secure by default
|
||||
- ✅ Easy to use (3 lines of code)
|
||||
- ✅ Well-tested and documented
|
||||
- ✅ Compatible with Claude Code and other AI tools
|
||||
|
||||
**Next focus:** Agent SDKs and developer tools to drive adoption.
|
||||
|
||||
---
|
||||
|
||||
**For detailed technical analysis, see [PROJECT_STATUS_2026.md](./PROJECT_STATUS_2026.md)**
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Go Micro is a framework for distributed systems development.
|
||||
|
||||
**[📖 Documentation](https://go-micro.dev/docs/)** | [Sponsor the project](https://github.com/sponsors/micro) | [Discord](https://discord.gg/jwTYuUVAGh)
|
||||
**[📖 Documentation](https://go-micro.dev/docs/)** | [Sponsored by Anthropic](https://go-micro.dev/blog/3)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -24,6 +24,10 @@ Go Micro abstracts away the details of distributed systems. Here are the main fe
|
||||
- **Data Storage** - A simple data store interface to read, write and delete records. It includes support for many storage backends
|
||||
in the plugins repo. State and persistence becomes a core requirement beyond prototyping and Micro looks to build that into the framework.
|
||||
|
||||
- **Data Model** - A typed data model layer with CRUD operations, queries, and multiple backends (memory, SQLite, Postgres). Define Go
|
||||
structs with tags and get type-safe Create/Read/Update/Delete/List/Count operations. Accessible via `service.Model()` alongside
|
||||
`service.Client()` and `service.Server()` for a complete service experience: call services, handle requests, save and query data.
|
||||
|
||||
- **Service Discovery** - Automatic service registration and name resolution. Service discovery is at the core of micro service
|
||||
development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is
|
||||
multicast DNS (mdns), a zeroconf system.
|
||||
@@ -45,6 +49,10 @@ in the plugins repo. State and persistence becomes a core requirement beyond pro
|
||||
- **MCP Integration** - An MCP gateway you can integrate as a library, server or CLI command which automatically exposes services
|
||||
as tools for agents or other AI applications. Every service/endpoint get's converted into a callable tool.
|
||||
|
||||
- **Multi-Service Binaries** - Run multiple services in a single process with isolated state per service. Start as a modular monolith,
|
||||
split into separate deployments when you need independent scaling. Each service gets its own server, client, and store while sharing
|
||||
the registry and broker for inter-service communication.
|
||||
|
||||
- **Pluggable Interfaces** - Go Micro makes use of Go interfaces for each distributed system abstraction. Because of this these interfaces
|
||||
are pluggable and allows Go Micro to be runtime agnostic. You can plugin any underlying technology.
|
||||
|
||||
@@ -53,7 +61,7 @@ in the plugins repo. State and persistence becomes a core requirement beyond pro
|
||||
To make use of Go Micro
|
||||
|
||||
```bash
|
||||
go get go-micro.dev/v5@latest
|
||||
go get go-micro.dev/v5@v5.16.0
|
||||
```
|
||||
|
||||
Create a service and register a handler
|
||||
@@ -95,10 +103,7 @@ func main() {
|
||||
Set a fixed address
|
||||
|
||||
```go
|
||||
service := micro.NewService(
|
||||
micro.Name("helloworld"),
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
service := micro.New("helloworld", micro.Address(":8080"))
|
||||
```
|
||||
|
||||
Call it via curl
|
||||
@@ -144,11 +149,104 @@ Use `micro mcp serve` for local AI tools like Claude Code, or connect any MCP-co
|
||||
|
||||
See the [MCP guide](https://go-micro.dev/docs/mcp.html) for authentication, scopes, and advanced usage.
|
||||
|
||||
## Multi-Service Binaries
|
||||
|
||||
Run multiple services in a single binary — start as a modular monolith, split into separate deployments later when you actually need to.
|
||||
|
||||
```go
|
||||
users := micro.New("users", micro.Address(":9001"))
|
||||
orders := micro.New("orders", micro.Address(":9002"))
|
||||
|
||||
users.Handle(new(Users))
|
||||
orders.Handle(new(Orders))
|
||||
|
||||
// Run all services together with shared lifecycle
|
||||
g := micro.NewGroup(users, orders)
|
||||
g.Run()
|
||||
```
|
||||
|
||||
Each service gets its own server, client, store, and cache while sharing the registry, broker, and transport — so they can discover and call each other within the same process.
|
||||
|
||||
See the [multi-service example](examples/multi-service/) for a working demo.
|
||||
|
||||
## Data Model
|
||||
|
||||
Go Micro includes a typed data model layer for persistence. Define a struct, tag a key field, and get type-safe CRUD and query operations backed by memory, SQLite, or Postgres.
|
||||
|
||||
```go
|
||||
import (
|
||||
"go-micro.dev/v5/model"
|
||||
"go-micro.dev/v5/model/sqlite"
|
||||
)
|
||||
|
||||
// Define your data type
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" model:"index"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
```
|
||||
|
||||
Register your types and use the model:
|
||||
|
||||
```go
|
||||
service := micro.New("users")
|
||||
|
||||
// Register and use the service's model backend
|
||||
db := service.Model()
|
||||
db.Register(&User{})
|
||||
|
||||
// CRUD operations
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
|
||||
|
||||
user := &User{}
|
||||
db.Read(ctx, "1", user)
|
||||
|
||||
user.Name = "Alice Smith"
|
||||
db.Update(ctx, user)
|
||||
|
||||
db.Delete(ctx, "1", &User{})
|
||||
```
|
||||
|
||||
Query with filters, ordering, and pagination:
|
||||
|
||||
```go
|
||||
var results []*User
|
||||
|
||||
// Find users by field
|
||||
db.List(ctx, &results, model.Where("email", "alice@example.com"))
|
||||
|
||||
// Complex queries
|
||||
db.List(ctx, &results,
|
||||
model.WhereOp("age", ">=", 18),
|
||||
model.OrderDesc("name"),
|
||||
model.Limit(10),
|
||||
model.Offset(20),
|
||||
)
|
||||
|
||||
count, _ := users.Count(ctx, model.Where("age", 30))
|
||||
```
|
||||
|
||||
Swap backends with an option:
|
||||
|
||||
```go
|
||||
// Development: in-memory (default)
|
||||
service := micro.New("users")
|
||||
|
||||
// Production: SQLite or Postgres
|
||||
db, _ := sqlite.New(model.WithDSN("file:app.db"))
|
||||
service := micro.New("users", micro.Model(db))
|
||||
```
|
||||
|
||||
Every service gets `Client()`, `Server()`, and `Model()` — call services, handle requests, and save data all from the same interface.
|
||||
|
||||
## Examples
|
||||
|
||||
Check out [/examples](examples/) for runnable code:
|
||||
- [hello-world](examples/hello-world/) - Basic RPC service
|
||||
- [web-service](examples/web-service/) - HTTP REST API
|
||||
- [multi-service](examples/multi-service/) - Multiple services in one binary
|
||||
- [mcp](examples/mcp/) - MCP integration with AI agents
|
||||
|
||||
See [all examples](examples/README.md) for more.
|
||||
@@ -274,6 +372,7 @@ Package reference: https://pkg.go.dev/go-micro.dev/v5
|
||||
|
||||
**User Guides:**
|
||||
- [Getting Started](internal/website/docs/getting-started.md)
|
||||
- [Data Model](internal/website/docs/model.md)
|
||||
- [MCP & AI Agents](internal/website/docs/mcp.md)
|
||||
- [Plugins Overview](internal/website/docs/plugins.md)
|
||||
- [Learn by Example](internal/website/docs/examples/index.md)
|
||||
|
||||
+31
-14
@@ -2,18 +2,27 @@
|
||||
|
||||
This roadmap outlines the planned features and improvements for Go Micro. Community feedback and contributions are welcome!
|
||||
|
||||
> **🚀 NEW:** See [ROADMAP_2026.md](ROADMAP_2026.md) for the **AI-Native Era roadmap** focused on MCP integration, agent-first development, and business sustainability. This document covers general framework improvements.
|
||||
> **See [internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md) for the AI-Native Era roadmap** focused on MCP integration, agent-first development, and business sustainability. This document covers general framework improvements.
|
||||
|
||||
## Current Focus (Q1 2026)
|
||||
## Current Focus (Q1 2026) - COMPLETE
|
||||
|
||||
### Documentation & Developer Experience
|
||||
- [x] Modernize documentation structure
|
||||
- [x] Add learn-by-example guides
|
||||
- [x] Update issue templates
|
||||
- [x] MCP integration documentation
|
||||
- [x] Agent playground and MCP tools registry
|
||||
- [ ] Create video tutorials
|
||||
- [ ] Interactive documentation site
|
||||
- [ ] Plugin discovery dashboard
|
||||
|
||||
### AI & Model Integration
|
||||
- [x] AI package with provider abstraction (`ai.Model` interface)
|
||||
- [x] Anthropic Claude provider (`ai/anthropic`)
|
||||
- [x] OpenAI GPT provider (`ai/openai`)
|
||||
- [x] Tool execution with auto-calling support
|
||||
- [x] Streaming support via `ai.Stream`
|
||||
|
||||
### Observability
|
||||
- [ ] OpenTelemetry native support
|
||||
- [ ] Auto-instrumentation for handlers
|
||||
@@ -22,7 +31,10 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
- [ ] Integration with popular observability platforms
|
||||
|
||||
### Developer Tools
|
||||
- [ ] `micro dev` with hot reload
|
||||
- [x] `micro run` with hot reload and unified gateway
|
||||
- [x] `micro deploy` with SSH + systemd deployment
|
||||
- [x] `micro mcp` command suite (serve, list, test, docs, export)
|
||||
- [ ] `micro dev` with enhanced hot reload
|
||||
- [ ] Service templates (`micro new --template`)
|
||||
- [ ] Better error messages with suggestions
|
||||
- [ ] Debug tooling improvements
|
||||
@@ -31,8 +43,8 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
## Q2 2026
|
||||
|
||||
### Production Readiness
|
||||
- [ ] Health check standardization
|
||||
- [ ] Graceful shutdown improvements
|
||||
- [x] Health check standardization
|
||||
- [x] Graceful shutdown improvements
|
||||
- [ ] Resource cleanup best practices
|
||||
- [ ] Load testing framework integration
|
||||
- [ ] Performance benchmarking suite
|
||||
@@ -45,6 +57,10 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
- [ ] Multi-cluster patterns
|
||||
|
||||
### Security
|
||||
- [x] Bearer token authentication for MCP
|
||||
- [x] Per-tool scope enforcement
|
||||
- [x] Audit logging
|
||||
- [x] Rate limiting
|
||||
- [ ] mTLS by default option
|
||||
- [ ] Secret management integration (Vault, AWS Secrets Manager)
|
||||
- [ ] RBAC improvements
|
||||
@@ -62,7 +78,7 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
|
||||
### Streaming & Async
|
||||
- [ ] Improved streaming support
|
||||
- [ ] Server-sent events (SSE) support
|
||||
- [x] Server-sent events (SSE) support (via MCP gateway)
|
||||
- [ ] WebSocket plugin
|
||||
- [ ] Event sourcing patterns
|
||||
- [ ] CQRS examples
|
||||
@@ -116,6 +132,7 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
### Differentiation
|
||||
- **Batteries included, fully swappable** - Start simple, scale complex
|
||||
- **Zero-config local development** - No infrastructure required to start
|
||||
- **AI-native by default** - Every service is an MCP tool automatically
|
||||
- **Plugin ecosystem in-repo** - No version compatibility hell
|
||||
- **Progressive complexity** - Learn as you grow
|
||||
- **Cloud-native first** - Built for Kubernetes and containers
|
||||
@@ -125,11 +142,11 @@ This roadmap outlines the planned features and improvements for Go Micro. Commun
|
||||
We welcome contributions to any roadmap items! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
### High Priority Areas
|
||||
1. Documentation improvements
|
||||
2. Real-world examples
|
||||
3. Plugin development
|
||||
4. Performance optimizations
|
||||
5. Testing infrastructure
|
||||
1. Documentation improvements (guides, tutorials)
|
||||
2. Multi-protocol MCP support (WebSocket, gRPC)
|
||||
3. Agent SDK integrations (LlamaIndex, AutoGPT)
|
||||
4. OpenTelemetry integration
|
||||
5. Kubernetes operator and Helm charts
|
||||
|
||||
### How to Contribute
|
||||
- Pick an item from the roadmap
|
||||
@@ -139,7 +156,7 @@ We welcome contributions to any roadmap items! See [CONTRIBUTING.md](CONTRIBUTIN
|
||||
|
||||
## Feedback
|
||||
|
||||
Have suggestions for the roadmap?
|
||||
Have suggestions for the roadmap?
|
||||
|
||||
- Open a [feature request](.github/ISSUE_TEMPLATE/feature_request.md)
|
||||
- Start a discussion in GitHub Discussions
|
||||
@@ -160,6 +177,6 @@ We follow semantic versioning:
|
||||
|
||||
---
|
||||
|
||||
Last updated: November 2025
|
||||
Last updated: March 2026
|
||||
|
||||
This roadmap is subject to change based on community needs and priorities. Star the repo to stay updated! ⭐
|
||||
This roadmap is subject to change based on community needs and priorities.
|
||||
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
# AI Package
|
||||
|
||||
The `ai` package provides a simple, high-level interface for AI model providers like Anthropic Claude and OpenAI GPT.
|
||||
|
||||
## Interface
|
||||
|
||||
The Model interface follows the same patterns as other go-micro packages (Registry, Client, Broker):
|
||||
|
||||
```go
|
||||
type Model interface {
|
||||
Init(...Option) error
|
||||
Options() Options
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
)
|
||||
|
||||
// Create a model
|
||||
m := ai.New("openai",
|
||||
ai.WithAPIKey("your-api-key"),
|
||||
ai.WithModel("gpt-4o"),
|
||||
)
|
||||
|
||||
// Generate a response
|
||||
req := &ai.Request{
|
||||
Prompt: "What is Go?",
|
||||
SystemPrompt: "You are a helpful programming assistant",
|
||||
}
|
||||
|
||||
resp, err := m.Generate(context.Background(), req)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Reply)
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
Configure the model using functional options:
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic",
|
||||
ai.WithAPIKey("your-key"), // Required
|
||||
ai.WithModel("claude-sonnet-4-20250514"), // Optional, uses provider default
|
||||
ai.WithBaseURL("https://api.anthropic.com"), // Optional, uses provider default
|
||||
)
|
||||
```
|
||||
|
||||
You can also update options after creation:
|
||||
|
||||
```go
|
||||
m.Init(
|
||||
ai.WithModel("gpt-4o-mini"),
|
||||
ai.WithAPIKey("new-key"),
|
||||
)
|
||||
```
|
||||
|
||||
## Using Tools
|
||||
|
||||
The model can automatically execute tool calls when provided with a tool handler:
|
||||
|
||||
```go
|
||||
// Define a tool handler
|
||||
toolHandler := func(name string, input map[string]any) (result any, content string) {
|
||||
// Execute the tool and return results
|
||||
switch name {
|
||||
case "get_weather":
|
||||
return map[string]string{"temp": "72F"}, `{"temp": "72F"}`
|
||||
default:
|
||||
return nil, `{"error": "unknown tool"}`
|
||||
}
|
||||
}
|
||||
|
||||
// Create model with tool handler
|
||||
m := ai.New("openai",
|
||||
ai.WithAPIKey("your-key"),
|
||||
ai.WithToolHandler(toolHandler),
|
||||
)
|
||||
|
||||
// Provide tools in the request
|
||||
req := &ai.Request{
|
||||
Prompt: "What's the weather?",
|
||||
SystemPrompt: "You are a helpful assistant",
|
||||
Tools: []ai.Tool{
|
||||
{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Properties: map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate will automatically call tools and return final answer
|
||||
resp, err := m.Generate(context.Background(), req)
|
||||
fmt.Println(resp.Answer) // Final answer after tool execution
|
||||
```
|
||||
|
||||
## Response Structure
|
||||
|
||||
```go
|
||||
type Response struct {
|
||||
Reply string // Initial reply from model
|
||||
ToolCalls []ToolCall // Tools the model wants to call
|
||||
Answer string // Final answer (after tool execution if handler provided)
|
||||
}
|
||||
```
|
||||
|
||||
- `Reply`: The model's first response
|
||||
- `ToolCalls`: List of tools the model requested (if any)
|
||||
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Anthropic Claude
|
||||
|
||||
```go
|
||||
m := ai.New("anthropic",
|
||||
ai.WithAPIKey("sk-ant-..."),
|
||||
ai.WithModel("claude-sonnet-4-20250514"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `claude-sonnet-4-20250514`
|
||||
Default base URL: `https://api.anthropic.com`
|
||||
|
||||
### OpenAI GPT
|
||||
|
||||
```go
|
||||
m := ai.New("openai",
|
||||
ai.WithAPIKey("sk-..."),
|
||||
ai.WithModel("gpt-4o"), // default
|
||||
)
|
||||
```
|
||||
|
||||
Default model: `gpt-4o`
|
||||
Default base URL: `https://api.openai.com`
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
Use `AutoDetectProvider()` to detect the provider from a base URL:
|
||||
|
||||
```go
|
||||
provider := ai.AutoDetectProvider("https://api.anthropic.com")
|
||||
// Returns "anthropic"
|
||||
|
||||
m := ai.New(provider, ai.WithAPIKey("..."))
|
||||
```
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
1. Create a new package under `ai/`:
|
||||
|
||||
```go
|
||||
package myprovider
|
||||
|
||||
import "go-micro.dev/v5/ai"
|
||||
|
||||
func init() {
|
||||
ai.Register("myprovider", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
// Set defaults
|
||||
if options.Model == "" {
|
||||
options.Model = "my-default-model"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.myprovider.com"
|
||||
}
|
||||
return &Provider{opts: options}
|
||||
}
|
||||
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
func (p *Provider) String() string {
|
||||
return "myprovider"
|
||||
}
|
||||
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
// Implement your provider logic
|
||||
// - Build API request
|
||||
// - Make HTTP call
|
||||
// - Parse response
|
||||
// - Handle tools if ToolHandler is set
|
||||
return &ai.Response{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
2. Import your provider:
|
||||
|
||||
```go
|
||||
import _ "go-micro.dev/v5/ai/myprovider"
|
||||
```
|
||||
|
||||
## Comparison with Other Packages
|
||||
|
||||
The ai package follows the same patterns as other go-micro packages:
|
||||
|
||||
**Registry:**
|
||||
```go
|
||||
r := registry.NewRegistry(registry.Addrs("..."))
|
||||
r.Register(service)
|
||||
```
|
||||
|
||||
**Client:**
|
||||
```go
|
||||
c := client.NewClient(client.Retries(3))
|
||||
c.Call(ctx, req, rsp)
|
||||
```
|
||||
|
||||
**AI:**
|
||||
```go
|
||||
m := ai.New("openai", ai.WithAPIKey("..."))
|
||||
m.Generate(ctx, req)
|
||||
```
|
||||
|
||||
All use:
|
||||
- `Init()` to update options
|
||||
- `Options()` to get current options
|
||||
- `String()` to get the implementation name
|
||||
- Functional options pattern
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./ai/...
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See the [server implementation](../cmd/micro/server/server.go) for a complete example of using the ai package with tool execution.
|
||||
@@ -0,0 +1,227 @@
|
||||
// Package anthropic implements the Anthropic Claude model provider
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("anthropic", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for Anthropic Claude
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new Anthropic provider
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.anthropic.com"
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options returns the provider options
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
// String returns the provider name
|
||||
func (p *Provider) String() string {
|
||||
return "anthropic"
|
||||
}
|
||||
|
||||
// Generate generates a response from the model
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
// Build tools for Anthropic format
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 4096,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
apiReq["tools"] = anthropicTools
|
||||
}
|
||||
|
||||
// Make API call
|
||||
resp, rawContent, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no tool calls, return response
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// If tool handler is provided, execute tools and get final answer
|
||||
if p.opts.ToolHandler != nil {
|
||||
var toolResults []ai.ToolResult
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
toolResults = append(toolResults, ai.ToolResult{
|
||||
ID: tc.ID,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// Build follow-up request with tool results
|
||||
var toolResultBlocks []map[string]any
|
||||
for _, tr := range toolResults {
|
||||
toolResultBlocks = append(toolResultBlocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tr.ID,
|
||||
"content": tr.Content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"max_tokens": 4096,
|
||||
"system": req.SystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"role": "assistant", "content": rawContent},
|
||||
{"role": "user", "content": toolResultBlocks},
|
||||
},
|
||||
}
|
||||
|
||||
// Make follow-up API call
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for anthropic provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the Anthropic API
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, any, error) {
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Build HTTP request
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/messages"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", p.opts.APIKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
|
||||
// Make request
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var anthropicResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &anthropicResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
response := &ai.Response{}
|
||||
|
||||
// Extract text reply
|
||||
var replyParts []string
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
replyParts = append(replyParts, block.Text)
|
||||
}
|
||||
}
|
||||
if len(replyParts) > 0 {
|
||||
response.Reply = strings.Join(replyParts, "\n")
|
||||
}
|
||||
|
||||
// Extract tool calls
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "tool_use" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: block.ID,
|
||||
Name: block.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return response, anthropicResp.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "anthropic" {
|
||||
t.Errorf("Expected provider name 'anthropic', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
err := p.Init(
|
||||
ai.WithModel("test-model"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL("https://test.com"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "test-model" {
|
||||
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
ai.WithModel("custom-model"),
|
||||
ai.WithAPIKey("my-key"),
|
||||
)
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "custom-model" {
|
||||
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.APIKey != "my-key" {
|
||||
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "claude-sonnet-4-20250514" {
|
||||
t.Errorf("Expected default model 'claude-sonnet-4-20250514', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.anthropic.com" {
|
||||
t.Errorf("Expected default base URL 'https://api.anthropic.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
SystemPrompt: "You are helpful",
|
||||
}
|
||||
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Package ai provides abstraction for AI model providers
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Model provides an interface for interacting with AI model providers
|
||||
type Model interface {
|
||||
// Init initializes the model with options
|
||||
Init(...Option) error
|
||||
// Options returns the model options
|
||||
Options() Options
|
||||
// Generate generates a response from the model
|
||||
Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error)
|
||||
// Stream generates a streaming response (for future implementation)
|
||||
Stream(ctx context.Context, req *Request, opts ...GenerateOption) (Stream, error)
|
||||
// String returns the name of the provider
|
||||
String() string
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that can be called by the model
|
||||
type Tool struct {
|
||||
Name string // LLM-safe name (e.g., "greeter_Greeter_Hello")
|
||||
OriginalName string // Original name (e.g., "greeter.Greeter.Hello")
|
||||
Description string
|
||||
Properties map[string]any // JSON schema for tool parameters
|
||||
}
|
||||
|
||||
// Request represents a request to generate content from a model
|
||||
type Request struct {
|
||||
// Prompt is the user's message/prompt
|
||||
Prompt string
|
||||
// SystemPrompt is the system instruction for the model
|
||||
SystemPrompt string
|
||||
// Tools available for the model to use
|
||||
Tools []Tool
|
||||
// Messages for continuing a conversation (optional)
|
||||
Messages []Message
|
||||
}
|
||||
|
||||
// Message represents a conversation message
|
||||
type Message struct {
|
||||
Role string // "user", "assistant", "system", "tool"
|
||||
Content any // Can be string or structured content
|
||||
}
|
||||
|
||||
// Response represents the response from a model
|
||||
type Response struct {
|
||||
// Reply is the text response from the model
|
||||
Reply string
|
||||
// ToolCalls are tool calls requested by the model
|
||||
ToolCalls []ToolCall
|
||||
// Answer is the final answer after tool execution (if tools were used)
|
||||
Answer string
|
||||
}
|
||||
|
||||
// ToolCall represents a request to call a tool
|
||||
type ToolCall struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Name string // Tool name
|
||||
Input map[string]any // Tool input arguments
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
ID string // Tool call ID (for correlation)
|
||||
Content string // Tool execution result (JSON string)
|
||||
}
|
||||
|
||||
// Stream is the interface for streaming responses (future implementation)
|
||||
type Stream interface {
|
||||
// Recv receives the next chunk of the response
|
||||
Recv() (*Response, error)
|
||||
// Close closes the stream
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ToolHandler is a function that handles tool calls
|
||||
type ToolHandler func(name string, input map[string]any) (result any, content string)
|
||||
|
||||
// NewFunc creates a new Model instance
|
||||
type NewFunc func(...Option) Model
|
||||
|
||||
var providers = make(map[string]NewFunc)
|
||||
|
||||
// Register registers a model provider
|
||||
func Register(name string, fn NewFunc) {
|
||||
providers[name] = fn
|
||||
}
|
||||
|
||||
// New creates a new Model instance based on the provider name
|
||||
func New(provider string, opts ...Option) Model {
|
||||
if fn, ok := providers[provider]; ok {
|
||||
return fn(opts...)
|
||||
}
|
||||
|
||||
// Default to first registered provider
|
||||
if len(providers) > 0 {
|
||||
for _, fn := range providers {
|
||||
return fn(opts...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoDetectProvider attempts to detect the provider from the base URL
|
||||
func AutoDetectProvider(baseURL string) string {
|
||||
if baseURL == "" {
|
||||
return "openai"
|
||||
}
|
||||
// Simple detection based on URL
|
||||
if strings.Contains(baseURL, "anthropic") {
|
||||
return "anthropic"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
// DefaultModel is a default model instance
|
||||
var DefaultModel Model
|
||||
|
||||
// Generate generates a response using the default model
|
||||
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
|
||||
if DefaultModel == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return DefaultModel.Generate(ctx, req, opts...)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package openai implements the OpenAI model provider
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func init() {
|
||||
ai.Register("openai", func(opts ...ai.Option) ai.Model {
|
||||
return NewProvider(opts...)
|
||||
})
|
||||
}
|
||||
|
||||
// Provider implements the ai.Model interface for OpenAI
|
||||
type Provider struct {
|
||||
opts ai.Options
|
||||
}
|
||||
|
||||
// NewProvider creates a new OpenAI provider
|
||||
func NewProvider(opts ...ai.Option) *Provider {
|
||||
options := ai.NewOptions(opts...)
|
||||
|
||||
// Set defaults if not provided
|
||||
if options.Model == "" {
|
||||
options.Model = "gpt-4o"
|
||||
}
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api.openai.com"
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
opts: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the provider with options
|
||||
func (p *Provider) Init(opts ...ai.Option) error {
|
||||
for _, o := range opts {
|
||||
o(&p.opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Options returns the provider options
|
||||
func (p *Provider) Options() ai.Options {
|
||||
return p.opts
|
||||
}
|
||||
|
||||
// String returns the provider name
|
||||
func (p *Provider) String() string {
|
||||
return "openai"
|
||||
}
|
||||
|
||||
// Generate generates a response from the model
|
||||
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
|
||||
// Build tools for OpenAI format
|
||||
var openaiTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
openaiTools = append(openaiTools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Build messages
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": req.SystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
|
||||
// Build initial request
|
||||
apiReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if len(openaiTools) > 0 {
|
||||
apiReq["tools"] = openaiTools
|
||||
}
|
||||
|
||||
// Make API call
|
||||
resp, rawMessage, err := p.callAPI(ctx, apiReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no tool calls, return response
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// If tool handler is provided, execute tools and get final answer
|
||||
if p.opts.ToolHandler != nil {
|
||||
// Build follow-up messages
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": rawMessage["content"],
|
||||
"tool_calls": rawMessage["tool_calls"],
|
||||
})
|
||||
|
||||
for _, tc := range resp.ToolCalls {
|
||||
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
followUpReq := map[string]any{
|
||||
"model": p.opts.Model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
|
||||
// Make follow-up API call
|
||||
followUpResp, _, err := p.callAPI(ctx, followUpReq)
|
||||
if err == nil && followUpResp.Reply != "" {
|
||||
resp.Answer = followUpResp.Reply
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Stream generates a streaming response (not yet implemented)
|
||||
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
|
||||
return nil, fmt.Errorf("streaming not yet implemented for openai provider")
|
||||
}
|
||||
|
||||
// callAPI makes an HTTP request to the OpenAI API
|
||||
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Build HTTP request
|
||||
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
|
||||
|
||||
// Make request
|
||||
httpResp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
// Read response
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != 200 {
|
||||
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, nil, fmt.Errorf("no response from API")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
response := &ai.Response{
|
||||
Reply: choice.Message.Content,
|
||||
}
|
||||
|
||||
// Extract tool calls
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
// Return raw message for potential follow-up
|
||||
rawMessage := map[string]any{
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
}
|
||||
|
||||
return response, rawMessage, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/ai"
|
||||
)
|
||||
|
||||
func TestProvider_String(t *testing.T) {
|
||||
p := NewProvider()
|
||||
if p.String() != "openai" {
|
||||
t.Errorf("Expected provider name 'openai', got '%s'", p.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
err := p.Init(
|
||||
ai.WithModel("test-model"),
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL("https://test.com"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "test-model" {
|
||||
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.APIKey != "test-key" {
|
||||
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
if opts.BaseURL != "https://test.com" {
|
||||
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Options(t *testing.T) {
|
||||
p := NewProvider(
|
||||
ai.WithModel("custom-model"),
|
||||
ai.WithAPIKey("my-key"),
|
||||
)
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "custom-model" {
|
||||
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.APIKey != "my-key" {
|
||||
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Defaults(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
opts := p.Options()
|
||||
if opts.Model != "gpt-4o" {
|
||||
t.Errorf("Expected default model 'gpt-4o', got '%s'", opts.Model)
|
||||
}
|
||||
if opts.BaseURL != "https://api.openai.com" {
|
||||
t.Errorf("Expected default base URL 'https://api.openai.com', got '%s'", opts.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Generate_NoAPIKey(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
SystemPrompt: "You are helpful",
|
||||
}
|
||||
|
||||
_, err := p.Generate(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error when API key is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Stream_NotImplemented(t *testing.T) {
|
||||
p := NewProvider()
|
||||
|
||||
req := &ai.Request{
|
||||
Prompt: "Hello",
|
||||
}
|
||||
|
||||
_, err := p.Stream(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Error("Expected error for unimplemented streaming, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Options for model configuration
|
||||
type Options struct {
|
||||
// Context for the model
|
||||
Context context.Context
|
||||
// Model name (e.g., "gpt-4o", "claude-sonnet-4-20250514")
|
||||
Model string
|
||||
// APIKey for authentication
|
||||
APIKey string
|
||||
// BaseURL for the API endpoint
|
||||
BaseURL string
|
||||
// ToolHandler handles tool calls (optional, for automatic tool execution)
|
||||
ToolHandler ToolHandler
|
||||
}
|
||||
|
||||
// GenerateOptions for generate call
|
||||
type GenerateOptions struct {
|
||||
// Context for this specific generate call
|
||||
Context context.Context
|
||||
}
|
||||
|
||||
// Option is a function that modifies Options
|
||||
type Option func(*Options)
|
||||
|
||||
// GenerateOption is a function that modifies GenerateOptions
|
||||
type GenerateOption func(*GenerateOptions)
|
||||
|
||||
// NewOptions creates new Options with defaults
|
||||
func NewOptions(opts ...Option) Options {
|
||||
options := Options{
|
||||
Context: context.Background(),
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(&options)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// WithModel sets the model name
|
||||
func WithModel(m string) Option {
|
||||
return func(o *Options) {
|
||||
o.Model = m
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the API key
|
||||
func WithAPIKey(key string) Option {
|
||||
return func(o *Options) {
|
||||
o.APIKey = key
|
||||
}
|
||||
}
|
||||
|
||||
// WithBaseURL sets the base URL
|
||||
func WithBaseURL(url string) Option {
|
||||
return func(o *Options) {
|
||||
o.BaseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithContext sets the context
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolHandler sets the tool handler
|
||||
func WithToolHandler(handler ToolHandler) Option {
|
||||
return func(o *Options) {
|
||||
o.ToolHandler = handler
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -20,9 +20,9 @@ import (
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/registry/cache"
|
||||
"go-micro.dev/v5/transport/headers"
|
||||
maddr "go-micro.dev/v5/util/addr"
|
||||
mnet "go-micro.dev/v5/util/net"
|
||||
mls "go-micro.dev/v5/util/tls"
|
||||
maddr "go-micro.dev/v5/internal/util/addr"
|
||||
mnet "go-micro.dev/v5/internal/util/net"
|
||||
mls "go-micro.dev/v5/internal/util/tls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "go-micro.dev/v5/logger"
|
||||
maddr "go-micro.dev/v5/util/addr"
|
||||
mnet "go-micro.dev/v5/util/net"
|
||||
maddr "go-micro.dev/v5/internal/util/addr"
|
||||
mnet "go-micro.dev/v5/internal/util/net"
|
||||
)
|
||||
|
||||
type memoryBroker struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"go-micro.dev/v5/logger"
|
||||
mtls "go-micro.dev/v5/util/tls"
|
||||
mtls "go-micro.dev/v5/internal/util/tls"
|
||||
)
|
||||
|
||||
type MQExchangeType string
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/util/backoff"
|
||||
"go-micro.dev/v5/internal/util/backoff"
|
||||
)
|
||||
|
||||
type BackoffFunc func(ctx context.Context, req Request, attempts int) (time.Duration, error)
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import (
|
||||
"go-micro.dev/v5/metadata"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/selector"
|
||||
pnet "go-micro.dev/v5/util/net"
|
||||
pnet "go-micro.dev/v5/internal/util/net"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/encoding"
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"go-micro.dev/v5/selector"
|
||||
"go-micro.dev/v5/transport"
|
||||
"go-micro.dev/v5/transport/headers"
|
||||
"go-micro.dev/v5/util/buf"
|
||||
"go-micro.dev/v5/util/net"
|
||||
"go-micro.dev/v5/util/pool"
|
||||
"go-micro.dev/v5/internal/util/buf"
|
||||
"go-micro.dev/v5/internal/util/net"
|
||||
"go-micro.dev/v5/internal/util/pool"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+31
-31
@@ -23,7 +23,7 @@ import (
|
||||
"go-micro.dev/v5/debug/trace"
|
||||
"go-micro.dev/v5/events"
|
||||
"go-micro.dev/v5/logger"
|
||||
mprofile "go-micro.dev/v5/profile"
|
||||
mprofile "go-micro.dev/v5/service/profile"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/registry/consul"
|
||||
"go-micro.dev/v5/registry/etcd"
|
||||
@@ -299,20 +299,37 @@ func init() {
|
||||
}
|
||||
|
||||
func newCmd(opts ...Option) Cmd {
|
||||
// Create local copies so each cmd instance is isolated.
|
||||
// This allows multiple services in a single binary without
|
||||
// conflicting through shared global pointers.
|
||||
localAuth := auth.DefaultAuth
|
||||
localBroker := broker.DefaultBroker
|
||||
localClient := client.DefaultClient
|
||||
localRegistry := registry.DefaultRegistry
|
||||
localServer := server.DefaultServer
|
||||
localSelector := selector.DefaultSelector
|
||||
localTransport := transport.DefaultTransport
|
||||
localStore := store.DefaultStore
|
||||
localTracer := trace.DefaultTracer
|
||||
localProfile := profile.DefaultProfile
|
||||
localConfig := config.DefaultConfig
|
||||
localCache := cache.DefaultCache
|
||||
localStream := events.DefaultStream
|
||||
|
||||
options := Options{
|
||||
Auth: &auth.DefaultAuth,
|
||||
Broker: &broker.DefaultBroker,
|
||||
Client: &client.DefaultClient,
|
||||
Registry: ®istry.DefaultRegistry,
|
||||
Server: &server.DefaultServer,
|
||||
Selector: &selector.DefaultSelector,
|
||||
Transport: &transport.DefaultTransport,
|
||||
Store: &store.DefaultStore,
|
||||
Tracer: &trace.DefaultTracer,
|
||||
DebugProfile: &profile.DefaultProfile,
|
||||
Config: &config.DefaultConfig,
|
||||
Cache: &cache.DefaultCache,
|
||||
Stream: &events.DefaultStream,
|
||||
Auth: &localAuth,
|
||||
Broker: &localBroker,
|
||||
Client: &localClient,
|
||||
Registry: &localRegistry,
|
||||
Server: &localServer,
|
||||
Selector: &localSelector,
|
||||
Transport: &localTransport,
|
||||
Store: &localStore,
|
||||
Tracer: &localTracer,
|
||||
DebugProfile: &localProfile,
|
||||
Config: &localConfig,
|
||||
Cache: &localCache,
|
||||
Stream: &localStream,
|
||||
|
||||
Brokers: DefaultBrokers,
|
||||
Clients: DefaultClients,
|
||||
@@ -381,13 +398,9 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
return fmt.Errorf("failed to load local profile: %v", ierr)
|
||||
}
|
||||
*c.opts.Registry = imported.Registry
|
||||
registry.DefaultRegistry = imported.Registry
|
||||
*c.opts.Broker = imported.Broker
|
||||
broker.DefaultBroker = imported.Broker
|
||||
*c.opts.Store = imported.Store
|
||||
store.DefaultStore = imported.Store
|
||||
*c.opts.Transport = imported.Transport
|
||||
transport.DefaultTransport = imported.Transport
|
||||
case "nats":
|
||||
imported, ierr := mprofile.NatsProfile()
|
||||
if ierr != nil {
|
||||
@@ -428,7 +441,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
// only change if we have the client and type differs
|
||||
if cl, ok := c.opts.Clients[name]; ok && (*c.opts.Client).String() != name {
|
||||
*c.opts.Client = cl()
|
||||
client.DefaultClient = *c.opts.Client
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +449,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
// only change if we have the server and type differs
|
||||
if s, ok := c.opts.Servers[name]; ok && (*c.opts.Server).String() != name {
|
||||
*c.opts.Server = s()
|
||||
server.DefaultServer = *c.opts.Server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +460,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
*c.opts.Store = s(store.WithClient(*c.opts.Client))
|
||||
store.DefaultStore = *c.opts.Store
|
||||
}
|
||||
|
||||
// Set the tracer
|
||||
@@ -460,7 +470,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
*c.opts.Tracer = r()
|
||||
trace.DefaultTracer = *c.opts.Tracer
|
||||
}
|
||||
|
||||
// Setup auth
|
||||
@@ -487,7 +496,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
*c.opts.Auth = r(authOpts...)
|
||||
auth.DefaultAuth = *c.opts.Auth
|
||||
}
|
||||
|
||||
// Set the registry
|
||||
@@ -509,7 +517,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
return fmt.Errorf("unsupported profile: %s", name)
|
||||
}
|
||||
*c.opts.DebugProfile = p()
|
||||
profile.DefaultProfile = *c.opts.DebugProfile
|
||||
}
|
||||
|
||||
// Set the broker
|
||||
@@ -534,7 +541,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
|
||||
// No server option here. Should there be?
|
||||
clientOpts = append(clientOpts, client.Selector(*c.opts.Selector))
|
||||
selector.DefaultSelector = *c.opts.Selector
|
||||
}
|
||||
|
||||
// Set the transport
|
||||
@@ -687,7 +693,6 @@ func (c *cmd) Before(ctx *cli.Context) error {
|
||||
logger.Fatalf("Error configuring config: %v", err)
|
||||
}
|
||||
*c.opts.Config = rc
|
||||
config.DefaultConfig = *c.opts.Config
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -709,7 +714,6 @@ func (c *cmd) setRegistry(r registry.Registry) ([]server.Option, []client.Option
|
||||
if err := (*c.opts.Broker).Init(broker.Registry(*c.opts.Registry)); err != nil {
|
||||
logger.Fatalf("Error configuring broker: %v", err)
|
||||
}
|
||||
registry.DefaultRegistry = *c.opts.Registry
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
func (c *cmd) setStream(s events.Stream) ([]server.Option, []client.Option) {
|
||||
@@ -720,7 +724,6 @@ func (c *cmd) setStream(s events.Stream) ([]server.Option, []client.Option) {
|
||||
// serverOpts = append(serverOpts, server.Registry(*c.opts.Registry))
|
||||
// clientOpts = append(clientOpts, client.Registry(*c.opts.Registry))
|
||||
|
||||
events.DefaultStream = *c.opts.Stream
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
@@ -730,7 +733,6 @@ func (c *cmd) setBroker(b broker.Broker) ([]server.Option, []client.Option) {
|
||||
*c.opts.Broker = b
|
||||
serverOpts = append(serverOpts, server.Broker(*c.opts.Broker))
|
||||
clientOpts = append(clientOpts, client.Broker(*c.opts.Broker))
|
||||
broker.DefaultBroker = *c.opts.Broker
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
@@ -738,7 +740,6 @@ func (c *cmd) setStore(s store.Store) ([]server.Option, []client.Option) {
|
||||
var serverOpts []server.Option
|
||||
var clientOpts []client.Option
|
||||
*c.opts.Store = s
|
||||
store.DefaultStore = *c.opts.Store
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
@@ -748,7 +749,6 @@ func (c *cmd) setTransport(t transport.Transport) ([]server.Option, []client.Opt
|
||||
*c.opts.Transport = t
|
||||
serverOpts = append(serverOpts, server.Transport(*c.opts.Transport))
|
||||
clientOpts = append(clientOpts, client.Transport(*c.opts.Transport))
|
||||
transport.DefaultTransport = *c.opts.Transport
|
||||
return serverOpts, clientOpts
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /micro-mcp-gateway ./cmd/micro-mcp-gateway
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /micro-mcp-gateway /usr/local/bin/micro-mcp-gateway
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["micro-mcp-gateway"]
|
||||
CMD ["--address", ":3000"]
|
||||
@@ -0,0 +1,242 @@
|
||||
// Command micro-mcp-gateway runs a standalone MCP gateway that discovers
|
||||
// go-micro services via a registry and exposes them as AI-accessible tools
|
||||
// through the Model Context Protocol.
|
||||
//
|
||||
// This is the production deployment binary for the MCP gateway, intended
|
||||
// to run independently of your services.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// # mDNS (development default)
|
||||
// micro-mcp-gateway --address :3000
|
||||
//
|
||||
// # Consul
|
||||
// micro-mcp-gateway --address :3000 --registry consul --registry-address consul:8500
|
||||
//
|
||||
// # etcd
|
||||
// micro-mcp-gateway --address :3000 --registry etcd --registry-address etcd:2379
|
||||
//
|
||||
// # With auth and rate limiting
|
||||
// micro-mcp-gateway --address :3000 --registry consul \
|
||||
// --rate-limit 100 --rate-burst 200 --audit
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/auth"
|
||||
"go-micro.dev/v5/auth/jwt"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/registry/consul"
|
||||
"go-micro.dev/v5/registry/etcd"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "micro-mcp-gateway",
|
||||
Usage: "Standalone MCP gateway for go-micro services",
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Usage: "Address to listen on",
|
||||
Value: ":3000",
|
||||
EnvVars: []string{"MCP_ADDRESS"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry",
|
||||
Usage: "Service registry (mdns, consul, etcd)",
|
||||
Value: "mdns",
|
||||
EnvVars: []string{"MICRO_REGISTRY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry-address",
|
||||
Usage: "Registry address (e.g., consul:8500, etcd:2379)",
|
||||
EnvVars: []string{"MICRO_REGISTRY_ADDRESS"},
|
||||
},
|
||||
&cli.Float64Flag{
|
||||
Name: "rate-limit",
|
||||
Usage: "Requests per second per tool (0 = unlimited)",
|
||||
EnvVars: []string{"MCP_RATE_LIMIT"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "rate-burst",
|
||||
Usage: "Rate limit burst size",
|
||||
Value: 20,
|
||||
EnvVars: []string{"MCP_RATE_BURST"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "auth",
|
||||
Usage: "Enable JWT authentication",
|
||||
EnvVars: []string{"MCP_AUTH"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "audit",
|
||||
Usage: "Enable audit logging to stdout",
|
||||
EnvVars: []string{"MCP_AUDIT"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "scope",
|
||||
Usage: "Tool scope requirement (format: tool=scope1,scope2)",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "circuit-breaker",
|
||||
Usage: "Circuit breaker max failures before opening (0 = disabled)",
|
||||
EnvVars: []string{"MCP_CIRCUIT_BREAKER"},
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "circuit-breaker-timeout",
|
||||
Usage: "Circuit breaker open-state timeout before half-open probe",
|
||||
Value: 30 * time.Second,
|
||||
EnvVars: []string{"MCP_CIRCUIT_BREAKER_TIMEOUT"},
|
||||
},
|
||||
},
|
||||
Action: run,
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(c *cli.Context) error {
|
||||
logger := log.New(os.Stdout, "[mcp-gateway] ", log.LstdFlags)
|
||||
|
||||
// Configure registry
|
||||
reg, err := newRegistry(c.String("registry"), c.String("registry-address"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: %w", err)
|
||||
}
|
||||
|
||||
// Build MCP options
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Address: c.String("address"),
|
||||
Context: ctx,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if rps := c.Float64("rate-limit"); rps > 0 {
|
||||
opts.RateLimit = &mcp.RateLimitConfig{
|
||||
RequestsPerSecond: rps,
|
||||
Burst: c.Int("rate-burst"),
|
||||
}
|
||||
logger.Printf("Rate limit: %.0f req/s, burst %d", rps, c.Int("rate-burst"))
|
||||
}
|
||||
|
||||
// Auth
|
||||
if c.Bool("auth") {
|
||||
opts.Auth = jwt.NewAuth()
|
||||
logger.Printf("JWT authentication enabled")
|
||||
}
|
||||
|
||||
// Scopes
|
||||
if scopes := c.StringSlice("scope"); len(scopes) > 0 {
|
||||
opts.Scopes = parseScopes(scopes)
|
||||
for tool, s := range opts.Scopes {
|
||||
logger.Printf("Scope: %s requires [%s]", tool, strings.Join(s, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Circuit breaker
|
||||
if maxFail := c.Int("circuit-breaker"); maxFail > 0 {
|
||||
opts.CircuitBreaker = &mcp.CircuitBreakerConfig{
|
||||
MaxFailures: maxFail,
|
||||
Timeout: c.Duration("circuit-breaker-timeout"),
|
||||
}
|
||||
logger.Printf("Circuit breaker: max %d failures, timeout %s", maxFail, c.Duration("circuit-breaker-timeout"))
|
||||
}
|
||||
|
||||
// Audit
|
||||
if c.Bool("audit") {
|
||||
opts.AuditFunc = func(r mcp.AuditRecord) {
|
||||
status := "ALLOWED"
|
||||
if !r.Allowed {
|
||||
status = "DENIED:" + r.DeniedReason
|
||||
}
|
||||
logger.Printf("[audit] %s tool=%s account=%s status=%s duration=%s",
|
||||
r.TraceID, r.Tool, r.AccountID, status, r.Duration)
|
||||
}
|
||||
logger.Printf("Audit logging enabled")
|
||||
}
|
||||
|
||||
// Print startup info
|
||||
logger.Printf("Starting MCP gateway on %s", c.String("address"))
|
||||
logger.Printf("Registry: %s", c.String("registry"))
|
||||
if addr := c.String("registry-address"); addr != "" {
|
||||
logger.Printf("Registry address: %s", addr)
|
||||
}
|
||||
|
||||
// Start gateway in background
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- mcp.ListenAndServe(opts.Address, opts)
|
||||
}()
|
||||
|
||||
// Wait for signal or error
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
logger.Printf("Received %s, shutting down...", sig)
|
||||
cancel()
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("gateway error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newRegistry(name, address string) (registry.Registry, error) {
|
||||
var opts []registry.Option
|
||||
if address != "" {
|
||||
opts = append(opts, registry.Addrs(strings.Split(address, ",")...))
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "mdns", "":
|
||||
return registry.NewMDNSRegistry(opts...), nil
|
||||
case "consul":
|
||||
return consul.NewConsulRegistry(opts...), nil
|
||||
case "etcd":
|
||||
return etcd.NewEtcdRegistry(opts...), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown registry %q (supported: mdns, consul, etcd)", name)
|
||||
}
|
||||
}
|
||||
|
||||
func parseScopes(raw []string) map[string][]string {
|
||||
scopes := make(map[string][]string)
|
||||
for _, s := range raw {
|
||||
parts := strings.SplitN(s, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
tool := strings.TrimSpace(parts[0])
|
||||
scopeList := strings.Split(parts[1], ",")
|
||||
for i := range scopeList {
|
||||
scopeList[i] = strings.TrimSpace(scopeList[i])
|
||||
}
|
||||
scopes[tool] = scopeList
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// Ensure auth.Auth interface is satisfied at compile time.
|
||||
var _ auth.Auth = jwt.NewAuth()
|
||||
+30
-4
@@ -39,9 +39,16 @@ func genProtoHandler(c *cli.Context) error {
|
||||
func init() {
|
||||
cmd.Register([]*cli.Command{
|
||||
{
|
||||
Name: "new",
|
||||
Usage: "Create a new service",
|
||||
Action: new.Run,
|
||||
Name: "new",
|
||||
Usage: "Create a new service",
|
||||
ArgsUsage: "[name]",
|
||||
Action: new.Run,
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "no-mcp",
|
||||
Usage: "Disable MCP gateway integration in generated code",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gen",
|
||||
@@ -71,6 +78,18 @@ func init() {
|
||||
{
|
||||
Name: "call",
|
||||
Usage: "Call a service",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "header",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Set request headers (can be used multiple times): --header 'Key:Value'",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "metadata",
|
||||
Aliases: []string{"m"},
|
||||
Usage: "Set request metadata (can be used multiple times): --metadata 'Key:Value'",
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
args := ctx.Args()
|
||||
|
||||
@@ -86,9 +105,16 @@ func init() {
|
||||
request = args.Get(2)
|
||||
}
|
||||
|
||||
// Create context with metadata if provided
|
||||
// Note: This is for the direct 'micro call' command.
|
||||
// Dynamic service calls (e.g., 'micro helloworld call') are handled in CallService.
|
||||
callCtx := context.TODO()
|
||||
callCtx = util.AddMetadataToContext(callCtx, ctx.StringSlice("metadata"))
|
||||
callCtx = util.AddMetadataToContext(callCtx, ctx.StringSlice("header"))
|
||||
|
||||
req := client.NewRequest(service, endpoint, &bytes.Frame{Data: []byte(request)})
|
||||
var rsp bytes.Frame
|
||||
err := client.Call(context.TODO(), req, &rsp)
|
||||
err := client.Call(callCtx, req, &rsp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -105,6 +105,21 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
|
||||
fmt.Printf("Deploying to %s...\n\n", target)
|
||||
|
||||
// Early validation: Check if the requested service exists before SSH checks
|
||||
filterService := c.String("service")
|
||||
if filterService != "" && cfg != nil {
|
||||
found := false
|
||||
for _, svc := range cfg.Services {
|
||||
if svc.Name == filterService {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(cfg.Services) > 0 {
|
||||
return fmt.Errorf("service '%s' not found in configuration", filterService)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Check SSH connectivity
|
||||
fmt.Print(" Checking SSH connection... ")
|
||||
if err := checkSSH(target); err != nil {
|
||||
@@ -129,14 +144,23 @@ func deploySSH(c *cli.Context, target string, cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
for _, svc := range sorted {
|
||||
services = append(services, svc.Name)
|
||||
// If --service flag is provided, only include that service
|
||||
if filterService == "" || svc.Name == filterService {
|
||||
services = append(services, svc.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single service project
|
||||
services = []string{filepath.Base(absDir)}
|
||||
|
||||
// If --service flag was provided for a single-service project, validate it matches
|
||||
if filterService != "" && filterService != services[0] {
|
||||
return fmt.Errorf("service '%s' not found (only '%s' available)", filterService, services[0])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Building binaries... ")
|
||||
if err := buildBinaries(absDir, cfg, c.Bool("build")); err != nil {
|
||||
if err := buildBinaries(absDir, cfg, c.Bool("build"), services); err != nil {
|
||||
fmt.Println("\u2717")
|
||||
return err
|
||||
}
|
||||
@@ -241,7 +265,7 @@ func checkServerInit(host, remotePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBinaries(absDir string, cfg *config.Config, forceBuild bool) error {
|
||||
func buildBinaries(absDir string, cfg *config.Config, forceBuild bool, servicesToBuild []string) error {
|
||||
binDir := filepath.Join(absDir, "bin")
|
||||
|
||||
// Check if we already have binaries and don't need to rebuild
|
||||
@@ -266,7 +290,19 @@ func buildBinaries(absDir string, cfg *config.Config, forceBuild bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a map for quick lookup of services to build
|
||||
// This provides O(1) lookup time and makes the code more maintainable
|
||||
shouldBuild := make(map[string]bool)
|
||||
for _, svcName := range servicesToBuild {
|
||||
shouldBuild[svcName] = true
|
||||
}
|
||||
|
||||
for _, svc := range sorted {
|
||||
// Only build services in the servicesToBuild list
|
||||
if !shouldBuild[svc.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
svcDir := filepath.Join(absDir, svc.Path)
|
||||
outPath := filepath.Join(binDir, svc.Name)
|
||||
|
||||
@@ -408,6 +444,9 @@ Before deploying, initialize the server:
|
||||
Then deploy:
|
||||
micro deploy user@server
|
||||
|
||||
Deploy a specific service (multi-service projects):
|
||||
micro deploy user@server --service users
|
||||
|
||||
With a micro.mu config, you can define named targets:
|
||||
deploy prod
|
||||
ssh user@prod.example.com
|
||||
@@ -442,6 +481,10 @@ The deploy process:
|
||||
Name: "build",
|
||||
Usage: "Force rebuild of binaries",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service",
|
||||
Usage: "Deploy only a specific service (for multi-service projects)",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,15 +174,23 @@ func Run(ctx *cli.Context) error {
|
||||
}
|
||||
goDir = filepath.Join(goPath, "src", path.Clean(dir))
|
||||
|
||||
noMCP := ctx.Bool("no-mcp")
|
||||
|
||||
// Select main.go template based on MCP flag
|
||||
mainTmpl := tmpl.MainSRV
|
||||
if noMCP {
|
||||
mainTmpl = tmpl.MainSRVNoMCP
|
||||
}
|
||||
|
||||
c := config{
|
||||
Alias: dir,
|
||||
Comments: nil, // Remove redundant protoComments
|
||||
Comments: nil,
|
||||
Dir: dir,
|
||||
GoDir: goDir,
|
||||
GoPath: goPath,
|
||||
UseGoPath: false,
|
||||
Files: []file{
|
||||
{"main.go", tmpl.MainSRV},
|
||||
{"main.go", mainTmpl},
|
||||
{"handler/" + dir + ".go", tmpl.HandlerSRV},
|
||||
{"proto/" + dir + ".proto", tmpl.ProtoSRV},
|
||||
{"Makefile", tmpl.Makefile},
|
||||
@@ -214,7 +222,18 @@ func Run(ctx *cli.Context) error {
|
||||
fmt.Println("\nProject structure after 'make proto':")
|
||||
printTree(dir)
|
||||
|
||||
fmt.Println("\nService created successfully! Start coding in your new service directory.")
|
||||
fmt.Println()
|
||||
fmt.Printf("Service %s created successfully!\n\n", dir)
|
||||
fmt.Println("Next steps:")
|
||||
fmt.Printf(" cd %s\n", dir)
|
||||
fmt.Println(" go run .")
|
||||
if !noMCP {
|
||||
fmt.Println()
|
||||
fmt.Println("Your service is MCP-enabled. Once running:")
|
||||
fmt.Println(" MCP tools: http://localhost:3001/mcp/tools")
|
||||
fmt.Println(" Claude Code: micro mcp serve")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,24 @@ import (
|
||||
|
||||
type {{title .Alias}} struct{}
|
||||
|
||||
// Return a new handler
|
||||
// Return a new handler.
|
||||
func New() *{{title .Alias}} {
|
||||
return &{{title .Alias}}{}
|
||||
}
|
||||
|
||||
// Call is a single request handler called via client.Call or the generated client code
|
||||
// Call greets a person by name and returns a welcome message.
|
||||
//
|
||||
// @example {"name": "Alice"}
|
||||
func (e *{{title .Alias}}) Call(ctx context.Context, req *pb.Request, rsp *pb.Response) error {
|
||||
log.Info("Received {{title .Alias}}.Call request")
|
||||
rsp.Msg = "Hello " + req.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stream is a server side stream handler called via client.Stream or the generated client code
|
||||
// Stream sends a sequence of numbered responses back to the caller.
|
||||
// Use this for streaming large result sets or real-time updates.
|
||||
//
|
||||
// @example {"count": 5}
|
||||
func (e *{{title .Alias}}) Stream(ctx context.Context, req *pb.StreamingRequest, stream pb.{{title .Alias}}_StreamStream) error {
|
||||
log.Infof("Received {{title .Alias}}.Stream request with count: %d", req.Count)
|
||||
|
||||
|
||||
@@ -3,6 +3,33 @@ package template
|
||||
var (
|
||||
MainSRV = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
pb "{{.Dir}}/proto"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create service
|
||||
service := micro.New("{{lower .Alias}}",
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
|
||||
// Initialize service
|
||||
service.Init()
|
||||
|
||||
// Register handler
|
||||
pb.Register{{title .Alias}}Handler(service.Server(), handler.New())
|
||||
|
||||
// Run service
|
||||
service.Run()
|
||||
}
|
||||
`
|
||||
|
||||
MainSRVNoMCP = `package main
|
||||
|
||||
import (
|
||||
"{{.Dir}}/handler"
|
||||
pb "{{.Dir}}/proto"
|
||||
|
||||
@@ -27,6 +27,18 @@ test-coverage:
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# List MCP tools exposed by this service
|
||||
mcp-tools:
|
||||
micro mcp list
|
||||
|
||||
# Test an MCP tool interactively
|
||||
mcp-test:
|
||||
micro mcp test
|
||||
|
||||
# Start MCP server for Claude Code
|
||||
mcp-serve:
|
||||
micro mcp serve
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/ coverage.out coverage.html
|
||||
@@ -35,14 +47,6 @@ clean:
|
||||
docker:
|
||||
docker build -t {{.Alias}}:latest .
|
||||
|
||||
# Run with Docker Compose
|
||||
docker-up:
|
||||
docker-compose up -d
|
||||
|
||||
# Stop Docker Compose
|
||||
docker-down:
|
||||
docker-compose down
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
@@ -3,7 +3,7 @@ package template
|
||||
var (
|
||||
Module = `module {{.Dir}}
|
||||
|
||||
go 1.18
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
go-micro.dev/v5 latest
|
||||
|
||||
@@ -17,18 +17,22 @@ message Message {
|
||||
}
|
||||
|
||||
message Request {
|
||||
// Name of the person to greet
|
||||
string name = 1;
|
||||
}
|
||||
|
||||
message Response {
|
||||
// Greeting message
|
||||
string msg = 1;
|
||||
}
|
||||
|
||||
message StreamingRequest {
|
||||
// Number of responses to stream back
|
||||
int64 count = 1;
|
||||
}
|
||||
|
||||
message StreamingResponse {
|
||||
// Current sequence number in the stream
|
||||
int64 count = 1;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -3,28 +3,88 @@ package template
|
||||
var (
|
||||
Readme = `# {{title .Alias}} Service
|
||||
|
||||
This is the {{title .Alias}} service
|
||||
|
||||
Generated with
|
||||
|
||||
` + "```" +
|
||||
`
|
||||
` + "```" + `
|
||||
micro new {{.Alias}}
|
||||
` + "```" + `
|
||||
|
||||
## Usage
|
||||
## Getting Started
|
||||
|
||||
Generate the proto code
|
||||
Generate the proto code:
|
||||
|
||||
` + "```" +
|
||||
`
|
||||
` + "```bash" + `
|
||||
make proto
|
||||
` + "```" + `
|
||||
|
||||
Run the service
|
||||
Run the service:
|
||||
|
||||
` + "```" +
|
||||
`
|
||||
micro run .
|
||||
` + "```"
|
||||
` + "```bash" + `
|
||||
go run .
|
||||
` + "```" + `
|
||||
|
||||
## MCP & AI Agents
|
||||
|
||||
This service is MCP-enabled by default. When running, AI agents can discover
|
||||
and call your service endpoints automatically.
|
||||
|
||||
**MCP tools endpoint:** http://localhost:3001/mcp/tools
|
||||
|
||||
### Test with curl
|
||||
|
||||
` + "```bash" + `
|
||||
# List available tools
|
||||
curl http://localhost:3001/mcp/tools | jq
|
||||
|
||||
# Call the service via MCP
|
||||
curl -X POST http://localhost:3001/mcp/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool": "{{lower .Alias}}.{{title .Alias}}.Call", "arguments": {"name": "Alice"}}'
|
||||
` + "```" + `
|
||||
|
||||
### Use with Claude Code
|
||||
|
||||
` + "```bash" + `
|
||||
# Start MCP server for Claude Code
|
||||
micro mcp serve
|
||||
` + "```" + `
|
||||
|
||||
Or add to your Claude Code config:
|
||||
|
||||
` + "```json" + `
|
||||
{
|
||||
"mcpServers": {
|
||||
"{{lower .Alias}}": {
|
||||
"command": "micro",
|
||||
"args": ["mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
### Writing Good Tool Descriptions
|
||||
|
||||
AI agents work best when your handler methods have clear doc comments:
|
||||
|
||||
` + "```go" + `
|
||||
// CreateUser registers a new user account with the given email and name.
|
||||
// Returns the created user with their assigned ID.
|
||||
//
|
||||
// @example {"email": "alice@example.com", "name": "Alice Smith"}
|
||||
func (s *Users) CreateUser(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
// ...
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
See the [tool descriptions guide](https://go-micro.dev/docs/guides/tool-descriptions) for more tips.
|
||||
|
||||
## Development
|
||||
|
||||
` + "```bash" + `
|
||||
make proto # Regenerate proto code
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
make dev # Run with hot reload (requires air)
|
||||
` + "```" + `
|
||||
`
|
||||
)
|
||||
|
||||
@@ -15,9 +15,30 @@ import (
|
||||
"github.com/stretchr/objx"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/metadata"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
// AddMetadataToContext parses metadata strings in the format "Key:Value" and adds them to the context
|
||||
func AddMetadataToContext(ctx context.Context, metadataStrings []string) context.Context {
|
||||
if len(metadataStrings) == 0 {
|
||||
return ctx
|
||||
}
|
||||
|
||||
md := make(metadata.Metadata)
|
||||
for _, m := range metadataStrings {
|
||||
parts := strings.SplitN(m, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
md[key] = value
|
||||
}
|
||||
|
||||
return metadata.MergeContext(ctx, md, true)
|
||||
}
|
||||
|
||||
// LookupService queries the service for a service with the given alias. If
|
||||
// no services are found for a given alias, the registry will return nil and
|
||||
// the error will also be nil. An error is only returned if there was an issue
|
||||
@@ -132,17 +153,27 @@ func CallService(srv *registry.Service, args []string) error {
|
||||
return fmt.Errorf("Endpoint %v not found for service %v", endpoint, srv.Name)
|
||||
}
|
||||
|
||||
// parse the flags
|
||||
// create a context for the call
|
||||
callCtx := context.TODO()
|
||||
|
||||
// parse out --header or --metadata flags before parsing request body
|
||||
// Note: This is for dynamic service calls (e.g., 'micro helloworld call --header X:Y').
|
||||
// Direct 'micro call' commands are handled in cli.go.
|
||||
if headerFlags, ok := flags["header"]; ok {
|
||||
callCtx = AddMetadataToContext(callCtx, headerFlags)
|
||||
delete(flags, "header")
|
||||
}
|
||||
if metadataFlags, ok := flags["metadata"]; ok {
|
||||
callCtx = AddMetadataToContext(callCtx, metadataFlags)
|
||||
delete(flags, "metadata")
|
||||
}
|
||||
|
||||
// parse the flags into request body
|
||||
body, err := FlagsToRequest(flags, ep.Request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create a context for the call based on the cli context
|
||||
callCtx := context.TODO()
|
||||
|
||||
// TODO: parse out --header or --metadata
|
||||
|
||||
// construct and execute the request using the json content type
|
||||
req := client.DefaultClient.NewRequest(srv.Name, endpoint, body, client.WithContentType("application/json"))
|
||||
var rsp json.RawMessage
|
||||
@@ -387,7 +418,7 @@ func FlagsToRequest(flags map[string][]string, req *registry.Value) (map[string]
|
||||
// so we do that here
|
||||
if strings.Contains(key, "-") {
|
||||
parts := strings.Split(key, "-")
|
||||
for i, _ := range parts {
|
||||
for i := range parts {
|
||||
pToCreate := strings.Join(parts[0:i], ".")
|
||||
if i > 0 && i < len(parts) && !result.Has(pToCreate) {
|
||||
result.Set(pToCreate, map[string]interface{}{})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"go-micro.dev/v5/metadata"
|
||||
goregistry "go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
@@ -377,3 +379,75 @@ func TestDynamicFlagParsing(t *testing.T) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMetadataToContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
metadataStrs []string
|
||||
expectedKeys []string
|
||||
expectedValues []string
|
||||
}{
|
||||
{
|
||||
name: "Single metadata",
|
||||
metadataStrs: []string{"Key1:Value1"},
|
||||
expectedKeys: []string{"Key1"},
|
||||
expectedValues: []string{"Value1"},
|
||||
},
|
||||
{
|
||||
name: "Multiple metadata",
|
||||
metadataStrs: []string{"Key1:Value1", "Key2:Value2"},
|
||||
expectedKeys: []string{"Key1", "Key2"},
|
||||
expectedValues: []string{"Value1", "Value2"},
|
||||
},
|
||||
{
|
||||
name: "Metadata with spaces",
|
||||
metadataStrs: []string{"Key1: Value1 ", " Key2 : Value2"},
|
||||
expectedKeys: []string{"Key1", "Key2"},
|
||||
expectedValues: []string{"Value1", "Value2"},
|
||||
},
|
||||
{
|
||||
name: "Metadata with colon in value",
|
||||
metadataStrs: []string{"Authorization:Bearer token:123"},
|
||||
expectedKeys: []string{"Authorization"},
|
||||
expectedValues: []string{"Bearer token:123"},
|
||||
},
|
||||
{
|
||||
name: "Empty metadata",
|
||||
metadataStrs: []string{},
|
||||
expectedKeys: []string{},
|
||||
expectedValues: []string{},
|
||||
},
|
||||
{
|
||||
name: "Invalid metadata format",
|
||||
metadataStrs: []string{"InvalidFormat"},
|
||||
expectedKeys: []string{},
|
||||
expectedValues: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx = AddMetadataToContext(ctx, tt.metadataStrs)
|
||||
|
||||
md, ok := metadata.FromContext(ctx)
|
||||
if len(tt.expectedKeys) == 0 && !ok {
|
||||
return // Expected no metadata
|
||||
}
|
||||
|
||||
if !ok && len(tt.expectedKeys) > 0 {
|
||||
t.Fatal("Expected metadata in context but got none")
|
||||
}
|
||||
|
||||
for i, key := range tt.expectedKeys {
|
||||
value, found := md.Get(key)
|
||||
if !found {
|
||||
t.Fatalf("Expected key %s not found in metadata", key)
|
||||
}
|
||||
if value != tt.expectedValues[i] {
|
||||
t.Fatalf("Expected value %s for key %s, got %s", tt.expectedValues[i], key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
# MCP CLI Command Examples
|
||||
|
||||
This document provides examples of using the `micro mcp` commands for AI agent integration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [List Available Tools](#list-available-tools)
|
||||
- [Test a Tool](#test-a-tool)
|
||||
- [Generate Documentation](#generate-documentation)
|
||||
- [Export to Different Formats](#export-to-different-formats)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need at least one microservice running with the go-micro framework. The service will automatically be discovered via the registry (mdns by default).
|
||||
|
||||
Example service:
|
||||
```bash
|
||||
cd examples/mcp/hello
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## List Available Tools
|
||||
|
||||
### Human-readable list
|
||||
```bash
|
||||
micro mcp list
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Available MCP Tools:
|
||||
|
||||
Service: greeter
|
||||
• greeter.Greeter.SayHello
|
||||
|
||||
Total: 1 tools
|
||||
```
|
||||
|
||||
### JSON output
|
||||
```bash
|
||||
micro mcp list --json
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Call SayHello on greeter service",
|
||||
"endpoint": "Greeter.SayHello",
|
||||
"name": "greeter.Greeter.SayHello",
|
||||
"service": "greeter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test a Tool
|
||||
|
||||
### Basic test
|
||||
```bash
|
||||
micro mcp test greeter.Greeter.SayHello '{"name": "Alice"}'
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Testing tool: greeter.Greeter.SayHello
|
||||
Service: greeter
|
||||
Endpoint: Greeter.SayHello
|
||||
Input: {"name": "Alice"}
|
||||
|
||||
✅ Call successful!
|
||||
|
||||
Response:
|
||||
{
|
||||
"message": "Hello Alice!"
|
||||
}
|
||||
```
|
||||
|
||||
### Test with default empty input
|
||||
```bash
|
||||
micro mcp test greeter.Greeter.SayHello
|
||||
```
|
||||
|
||||
This will call the tool with an empty JSON object `{}`.
|
||||
|
||||
## Generate Documentation
|
||||
|
||||
### Markdown documentation (stdout)
|
||||
```bash
|
||||
micro mcp docs
|
||||
```
|
||||
|
||||
Output:
|
||||
```markdown
|
||||
# MCP Tools Documentation
|
||||
|
||||
Generated: 2026-02-13 14:30:00
|
||||
|
||||
Total Tools: 1
|
||||
|
||||
## Service: greeter
|
||||
|
||||
### greeter.Greeter.SayHello
|
||||
|
||||
**Description:** Greets a person by name. Returns a friendly greeting message.
|
||||
|
||||
**Example Input:**
|
||||
\`\`\`json
|
||||
{"name": "Alice"}
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
### Markdown documentation (save to file)
|
||||
```bash
|
||||
micro mcp docs --output mcp-tools.md
|
||||
```
|
||||
|
||||
This creates a `mcp-tools.md` file with the documentation.
|
||||
|
||||
### JSON documentation
|
||||
```bash
|
||||
micro mcp docs --format json
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"tools": [
|
||||
{
|
||||
"description": "Greets a person by name. Returns a friendly greeting message.",
|
||||
"endpoint": "Greeter.SayHello",
|
||||
"example": "{\"name\": \"Alice\"}",
|
||||
"metadata": {
|
||||
"description": "Greets a person by name. Returns a friendly greeting message.",
|
||||
"example": "{\"name\": \"Alice\"}"
|
||||
},
|
||||
"name": "greeter.Greeter.SayHello",
|
||||
"scopes": null,
|
||||
"service": "greeter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### JSON documentation (save to file)
|
||||
```bash
|
||||
micro mcp docs --format json --output tools.json
|
||||
```
|
||||
|
||||
## Export to Different Formats
|
||||
|
||||
### Export to LangChain (Python)
|
||||
|
||||
Generate Python code with LangChain tool definitions:
|
||||
|
||||
```bash
|
||||
micro mcp export langchain
|
||||
```
|
||||
|
||||
Output:
|
||||
```python
|
||||
# LangChain Tools for Go Micro Services
|
||||
# Auto-generated from MCP service discovery
|
||||
|
||||
from langchain.tools import Tool
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Configure your MCP gateway endpoint
|
||||
MCP_GATEWAY_URL = 'http://localhost:3000/mcp'
|
||||
|
||||
def call_mcp_tool(tool_name, arguments):
|
||||
"""Call an MCP tool via HTTP gateway"""
|
||||
response = requests.post(
|
||||
f'{MCP_GATEWAY_URL}/call',
|
||||
json={'name': tool_name, 'arguments': arguments}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# Define tools
|
||||
tools = []
|
||||
|
||||
def greeter_Greeter_SayHello(arguments: str) -> str:
|
||||
"""Greets a person by name. Returns a friendly greeting message."""
|
||||
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
||||
return json.dumps(call_mcp_tool('greeter.Greeter.SayHello', args))
|
||||
|
||||
tools.append(Tool(
|
||||
name='greeter.Greeter.SayHello',
|
||||
func=greeter_Greeter_SayHello,
|
||||
description='Greets a person by name. Returns a friendly greeting message.'
|
||||
))
|
||||
|
||||
# Example usage:
|
||||
# from langchain.agents import initialize_agent, AgentType
|
||||
# from langchain.llms import OpenAI
|
||||
#
|
||||
# llm = OpenAI(temperature=0)
|
||||
# agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)
|
||||
# agent.run('Your query here')
|
||||
```
|
||||
|
||||
Save to file:
|
||||
```bash
|
||||
micro mcp export langchain --output langchain_tools.py
|
||||
```
|
||||
|
||||
### Export to OpenAPI 3.0
|
||||
|
||||
Generate an OpenAPI specification:
|
||||
|
||||
```bash
|
||||
micro mcp export openapi
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"description": "Auto-generated OpenAPI spec from MCP service discovery",
|
||||
"title": "Go Micro MCP Services",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/mcp/call/greeter/Greeter/SayHello": {
|
||||
"post": {
|
||||
"description": "Greets a person by name. Returns a friendly greeting message.",
|
||||
"operationId": "greeter_Greeter_SayHello",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
}
|
||||
},
|
||||
"summary": "greeter.Greeter.SayHello"
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"description": "MCP Gateway",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Save to file:
|
||||
```bash
|
||||
micro mcp export openapi --output openapi.json
|
||||
```
|
||||
|
||||
### Export to raw JSON
|
||||
|
||||
Export raw tool definitions:
|
||||
|
||||
```bash
|
||||
micro mcp export json
|
||||
```
|
||||
|
||||
This is similar to `micro mcp docs --format json` but specifically for export purposes.
|
||||
|
||||
Save to file:
|
||||
```bash
|
||||
micro mcp export json --output tools.json
|
||||
```
|
||||
|
||||
## Using with Different Registries
|
||||
|
||||
By default, the commands use mdns registry. You can specify a different registry:
|
||||
|
||||
```bash
|
||||
# Using consul
|
||||
micro mcp list --registry consul --registry_address consul:8500
|
||||
|
||||
# Using etcd
|
||||
micro mcp list --registry etcd --registry_address etcd:2379
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Using LangChain Export with Claude
|
||||
|
||||
1. Export your tools to LangChain format:
|
||||
```bash
|
||||
micro mcp export langchain --output my_tools.py
|
||||
```
|
||||
|
||||
2. Use in your Python agent:
|
||||
```python
|
||||
from my_tools import tools
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain.chat_models import ChatAnthropic
|
||||
|
||||
llm = ChatAnthropic(model="claude-3-sonnet-20240229")
|
||||
agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)
|
||||
|
||||
result = agent.run("Greet Alice")
|
||||
print(result)
|
||||
```
|
||||
|
||||
### Using OpenAPI Export with GPT
|
||||
|
||||
1. Export to OpenAPI:
|
||||
```bash
|
||||
micro mcp export openapi --output openapi.json
|
||||
```
|
||||
|
||||
2. Upload to ChatGPT as a custom GPT action or use with OpenAI Assistants API.
|
||||
|
||||
### Documentation for AI Agents
|
||||
|
||||
Generate documentation that AI agents can read to understand your services:
|
||||
|
||||
```bash
|
||||
micro mcp docs --format json --output service-catalog.json
|
||||
```
|
||||
|
||||
This JSON file can be fed to AI agents for service discovery and understanding.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Piping and Processing
|
||||
|
||||
You can pipe the output to other tools:
|
||||
|
||||
```bash
|
||||
# Count tools per service
|
||||
micro mcp list --json | jq '.tools | group_by(.service) | map({service: .[0].service, count: length})'
|
||||
|
||||
# Extract all tool names
|
||||
micro mcp list --json | jq -r '.tools[].name'
|
||||
|
||||
# Filter tools by service
|
||||
micro mcp list --json | jq '.tools[] | select(.service == "greeter")'
|
||||
```
|
||||
|
||||
### Monitoring and CI/CD
|
||||
|
||||
Use these commands in your CI/CD pipeline:
|
||||
|
||||
```bash
|
||||
# Validate all services are discoverable
|
||||
SERVICE_COUNT=$(micro mcp list --json | jq '.count')
|
||||
if [ "$SERVICE_COUNT" -lt 5 ]; then
|
||||
echo "Error: Expected at least 5 services, found $SERVICE_COUNT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate documentation on each deployment
|
||||
micro mcp docs --output docs/mcp-services.md
|
||||
git add docs/mcp-services.md
|
||||
git commit -m "Update MCP service documentation"
|
||||
```
|
||||
|
||||
### Testing in Development
|
||||
|
||||
Create a script to test all your tools:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# test-all-tools.sh
|
||||
|
||||
TOOLS=$(micro mcp list --json | jq -r '.tools[].name')
|
||||
|
||||
for tool in $TOOLS; do
|
||||
echo "Testing $tool..."
|
||||
micro mcp test "$tool" "{}" || echo "Failed: $tool"
|
||||
done
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No tools found
|
||||
|
||||
If `micro mcp list` shows 0 tools:
|
||||
|
||||
1. Verify services are running:
|
||||
```bash
|
||||
ps aux | grep "your-service"
|
||||
```
|
||||
|
||||
2. Check registry (mdns might need time to discover):
|
||||
```bash
|
||||
# Wait a few seconds and try again
|
||||
sleep 3
|
||||
micro mcp list
|
||||
```
|
||||
|
||||
3. Use a different registry if mdns is unreliable:
|
||||
```bash
|
||||
# Start services with consul
|
||||
micro --registry consul server
|
||||
|
||||
# List with consul
|
||||
micro mcp list --registry consul
|
||||
```
|
||||
|
||||
### Service not responding in tests
|
||||
|
||||
If `micro mcp test` fails:
|
||||
|
||||
1. Verify the tool name is correct:
|
||||
```bash
|
||||
micro mcp list
|
||||
```
|
||||
|
||||
2. Check the JSON input format:
|
||||
```bash
|
||||
# Invalid
|
||||
micro mcp test service.Handler.Method '{invalid}'
|
||||
|
||||
# Valid
|
||||
micro mcp test service.Handler.Method '{"key": "value"}'
|
||||
```
|
||||
|
||||
3. Check service logs for errors.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [MCP Documentation](../../gateway/mcp/DOCUMENTATION.md)
|
||||
- Try the [MCP Examples](../../examples/mcp/README.md)
|
||||
- Learn about [Tool Scopes and Security](../../gateway/mcp/DOCUMENTATION.md#authentication-and-scopes)
|
||||
- Explore [Agent SDKs](#) (coming soon)
|
||||
+554
-3
@@ -8,10 +8,14 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
"go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
@@ -129,6 +133,83 @@ Example:
|
||||
},
|
||||
Action: testAction,
|
||||
},
|
||||
{
|
||||
Name: "docs",
|
||||
Usage: "Generate MCP documentation",
|
||||
Description: `Generate documentation for all available MCP tools.
|
||||
|
||||
The documentation includes tool names, descriptions, parameters, and examples
|
||||
extracted from service metadata and Go comments.
|
||||
|
||||
Examples:
|
||||
# Generate markdown documentation
|
||||
micro mcp docs
|
||||
|
||||
# Generate JSON documentation
|
||||
micro mcp docs --format json
|
||||
|
||||
# Save to file
|
||||
micro mcp docs --output mcp-tools.md`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "registry",
|
||||
Usage: "Registry for service discovery",
|
||||
Value: "mdns",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry_address",
|
||||
Usage: "Registry address",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Usage: "Output format (markdown, json)",
|
||||
Value: "markdown",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output file (default: stdout)",
|
||||
},
|
||||
},
|
||||
Action: docsAction,
|
||||
},
|
||||
{
|
||||
Name: "export",
|
||||
Usage: "Export tools to different formats",
|
||||
Description: `Export MCP tools to various agent framework formats.
|
||||
|
||||
Supported formats:
|
||||
- langchain: LangChain tool definitions (Python)
|
||||
- openapi: OpenAPI 3.0 specification
|
||||
- json: Raw JSON tool definitions
|
||||
|
||||
Examples:
|
||||
# Export to LangChain format
|
||||
micro mcp export langchain
|
||||
|
||||
# Export to OpenAPI
|
||||
micro mcp export openapi --output openapi.yaml
|
||||
|
||||
# Export raw JSON
|
||||
micro mcp export json`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "registry",
|
||||
Usage: "Registry for service discovery",
|
||||
Value: "mdns",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "registry_address",
|
||||
Usage: "Registry address",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output file (default: stdout)",
|
||||
},
|
||||
},
|
||||
Action: exportAction,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -247,10 +328,480 @@ func testAction(ctx *cli.Context) error {
|
||||
inputJSON = ctx.Args().Get(1)
|
||||
}
|
||||
|
||||
// Validate input JSON
|
||||
var inputData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inputJSON), &inputData); err != nil {
|
||||
return fmt.Errorf("invalid JSON input: %w", err)
|
||||
}
|
||||
|
||||
// Get registry
|
||||
reg := registry.DefaultRegistry
|
||||
if regName := ctx.String("registry"); regName != "" {
|
||||
if regName != "mdns" {
|
||||
return fmt.Errorf("registry %s not yet supported, use mdns", regName)
|
||||
}
|
||||
}
|
||||
|
||||
// Create MCP options
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Context: context.Background(),
|
||||
Logger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
|
||||
// Parse tool name (format: "service.endpoint" or "service.Handler.Method")
|
||||
parts := parseTool(toolName)
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid tool name format. Expected: service.endpoint or service.Handler.Method")
|
||||
}
|
||||
|
||||
serviceName := parts[0]
|
||||
endpointName := parts[1]
|
||||
|
||||
// If tool name has 3 parts, combine last two for endpoint (e.g., Handler.Method)
|
||||
if len(parts) == 3 {
|
||||
endpointName = parts[1] + "." + parts[2]
|
||||
}
|
||||
|
||||
// Discover the tool from registry
|
||||
services, err := opts.Registry.GetService(serviceName)
|
||||
if err != nil || len(services) == 0 {
|
||||
return fmt.Errorf("service %s not found: %w", serviceName, err)
|
||||
}
|
||||
|
||||
// Find the endpoint
|
||||
var endpoint *registry.Endpoint
|
||||
for _, ep := range services[0].Endpoints {
|
||||
if ep.Name == endpointName {
|
||||
endpoint = ep
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if endpoint == nil {
|
||||
return fmt.Errorf("endpoint %s not found in service %s", endpointName, serviceName)
|
||||
}
|
||||
|
||||
// Display test info
|
||||
fmt.Printf("Testing tool: %s\n", toolName)
|
||||
fmt.Printf("Input: %s\n", inputJSON)
|
||||
fmt.Println("\nResult:")
|
||||
fmt.Println("(Not yet implemented - coming soon)")
|
||||
fmt.Printf("Service: %s\n", serviceName)
|
||||
fmt.Printf("Endpoint: %s\n", endpointName)
|
||||
fmt.Printf("Input: %s\n\n", inputJSON)
|
||||
|
||||
// Convert input to JSON bytes for RPC call
|
||||
inputBytes, err := json.Marshal(inputData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal input: %w", err)
|
||||
}
|
||||
|
||||
// Make RPC call using bytes codec
|
||||
c := opts.Client
|
||||
if c == nil {
|
||||
c = client.DefaultClient
|
||||
}
|
||||
|
||||
// Create request with bytes frame
|
||||
req := c.NewRequest(serviceName, endpointName, &bytes.Frame{Data: inputBytes})
|
||||
|
||||
// Make the call
|
||||
var rsp bytes.Frame
|
||||
if err := c.Call(opts.Context, req, &rsp); err != nil {
|
||||
fmt.Printf("❌ Call failed: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse and display response
|
||||
fmt.Println("✅ Call successful!")
|
||||
fmt.Println("\nResponse:")
|
||||
|
||||
// Try to pretty-print JSON response
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(rsp.Data, &result); err == nil {
|
||||
prettyJSON, err := json.MarshalIndent(result, "", " ")
|
||||
if err == nil {
|
||||
fmt.Println(string(prettyJSON))
|
||||
} else {
|
||||
fmt.Println(string(rsp.Data))
|
||||
}
|
||||
} else {
|
||||
// Not JSON, print raw
|
||||
fmt.Println(string(rsp.Data))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTool splits a tool name into service and endpoint parts
|
||||
func parseTool(toolName string) []string {
|
||||
return strings.Split(toolName, ".")
|
||||
}
|
||||
|
||||
// docsAction generates documentation for MCP tools
|
||||
func docsAction(ctx *cli.Context) error {
|
||||
// Get registry
|
||||
reg := registry.DefaultRegistry
|
||||
|
||||
// Create temporary MCP server to discover tools
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Context: context.Background(),
|
||||
Logger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
|
||||
// Discover services
|
||||
services, err := opts.Registry.ListServices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
format := ctx.String("format")
|
||||
outputFile := ctx.String("output")
|
||||
|
||||
// Prepare output writer
|
||||
writer := os.Stdout
|
||||
if outputFile != "" {
|
||||
f, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
writer = f
|
||||
}
|
||||
|
||||
// Collect all tools with metadata
|
||||
type ToolDoc struct {
|
||||
Name string `json:"name"`
|
||||
Service string `json:"service"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Description string `json:"description"`
|
||||
Example string `json:"example,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
var tools []ToolDoc
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolDoc := ToolDoc{
|
||||
Name: fmt.Sprintf("%s.%s", svc.Name, ep.Name),
|
||||
Service: svc.Name,
|
||||
Endpoint: ep.Name,
|
||||
Description: fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name),
|
||||
Metadata: ep.Metadata,
|
||||
}
|
||||
|
||||
// Extract description from metadata if available
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
toolDoc.Description = desc
|
||||
}
|
||||
|
||||
// Extract example from metadata if available
|
||||
if example, ok := ep.Metadata["example"]; ok {
|
||||
toolDoc.Example = example
|
||||
}
|
||||
|
||||
// Extract scopes from metadata if available
|
||||
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
|
||||
toolDoc.Scopes = strings.Split(scopesStr, ",")
|
||||
}
|
||||
|
||||
tools = append(tools, toolDoc)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate output based on format
|
||||
switch format {
|
||||
case "json":
|
||||
enc := json.NewEncoder(writer)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]interface{}{
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
})
|
||||
|
||||
case "markdown":
|
||||
fmt.Fprintf(writer, "# MCP Tools Documentation\n\n")
|
||||
fmt.Fprintf(writer, "Generated: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(writer, "Total Tools: %d\n\n", len(tools))
|
||||
|
||||
|
||||
// Group by service
|
||||
serviceMap := make(map[string][]ToolDoc)
|
||||
for _, tool := range tools {
|
||||
serviceMap[tool.Service] = append(serviceMap[tool.Service], tool)
|
||||
}
|
||||
|
||||
for service, serviceTools := range serviceMap {
|
||||
fmt.Fprintf(writer, "## Service: %s\n\n", service)
|
||||
|
||||
for _, tool := range serviceTools {
|
||||
fmt.Fprintf(writer, "### %s\n\n", tool.Name)
|
||||
fmt.Fprintf(writer, "**Description:** %s\n\n", tool.Description)
|
||||
|
||||
if len(tool.Scopes) > 0 {
|
||||
fmt.Fprintf(writer, "**Required Scopes:** %s\n\n", strings.Join(tool.Scopes, ", "))
|
||||
}
|
||||
|
||||
if tool.Example != "" {
|
||||
fmt.Fprintf(writer, "**Example Input:**\n```json\n%s\n```\n\n", tool.Example)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported format: %s (supported: markdown, json)", format)
|
||||
}
|
||||
}
|
||||
|
||||
// exportAction exports tools to different formats
|
||||
func exportAction(ctx *cli.Context) error {
|
||||
if ctx.Args().Len() < 1 {
|
||||
return fmt.Errorf("usage: micro mcp export <format>\nSupported formats: langchain, openapi, json")
|
||||
}
|
||||
|
||||
exportFormat := ctx.Args().First()
|
||||
|
||||
// Get registry
|
||||
reg := registry.DefaultRegistry
|
||||
|
||||
// Create temporary MCP server to discover tools
|
||||
opts := mcp.Options{
|
||||
Registry: reg,
|
||||
Context: context.Background(),
|
||||
Logger: log.New(os.Stderr, "", 0),
|
||||
}
|
||||
|
||||
// Discover services
|
||||
services, err := opts.Registry.ListServices()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
outputFile := ctx.String("output")
|
||||
|
||||
// Prepare output writer
|
||||
writer := os.Stdout
|
||||
if outputFile != "" {
|
||||
f, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
writer = f
|
||||
}
|
||||
|
||||
switch exportFormat {
|
||||
case "langchain":
|
||||
return exportLangChain(writer, services, opts)
|
||||
case "openapi":
|
||||
return exportOpenAPI(writer, services, opts)
|
||||
case "json":
|
||||
return exportJSON(writer, services, opts)
|
||||
default:
|
||||
return fmt.Errorf("unsupported export format: %s\nSupported: langchain, openapi, json", exportFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// exportLangChain exports tools in LangChain format (Python)
|
||||
func exportLangChain(writer *os.File, services []*registry.Service, opts mcp.Options) error {
|
||||
fmt.Fprintf(writer, "# LangChain Tools for Go Micro Services\n")
|
||||
fmt.Fprintf(writer, "# Auto-generated from MCP service discovery\n\n")
|
||||
fmt.Fprintf(writer, "from langchain.tools import Tool\n")
|
||||
fmt.Fprintf(writer, "import requests\nimport json\n\n")
|
||||
fmt.Fprintf(writer, "# Configure your MCP gateway endpoint\n")
|
||||
fmt.Fprintf(writer, "MCP_GATEWAY_URL = 'http://localhost:3000/mcp'\n\n")
|
||||
|
||||
fmt.Fprintf(writer, "def call_mcp_tool(tool_name, arguments):\n")
|
||||
fmt.Fprintf(writer, " \"\"\"Call an MCP tool via HTTP gateway\"\"\"\n")
|
||||
fmt.Fprintf(writer, " response = requests.post(\n")
|
||||
fmt.Fprintf(writer, " f'{MCP_GATEWAY_URL}/call',\n")
|
||||
fmt.Fprintf(writer, " json={'name': tool_name, 'arguments': arguments}\n")
|
||||
fmt.Fprintf(writer, " )\n")
|
||||
fmt.Fprintf(writer, " response.raise_for_status()\n")
|
||||
fmt.Fprintf(writer, " return response.json()\n\n")
|
||||
|
||||
fmt.Fprintf(writer, "# Define tools\n")
|
||||
fmt.Fprintf(writer, "tools = []\n\n")
|
||||
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
||||
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
description = desc
|
||||
}
|
||||
|
||||
// Generate Python function name (replace dots with underscores)
|
||||
funcName := strings.ReplaceAll(toolName, ".", "_")
|
||||
|
||||
fmt.Fprintf(writer, "def %s(arguments: str) -> str:\n", funcName)
|
||||
fmt.Fprintf(writer, " \"\"\"% s\"\"\"\n", description)
|
||||
fmt.Fprintf(writer, " args = json.loads(arguments) if isinstance(arguments, str) else arguments\n")
|
||||
fmt.Fprintf(writer, " return json.dumps(call_mcp_tool('%s', args))\n\n", toolName)
|
||||
|
||||
fmt.Fprintf(writer, "tools.append(Tool(\n")
|
||||
fmt.Fprintf(writer, " name='%s',\n", toolName)
|
||||
fmt.Fprintf(writer, " func=%s,\n", funcName)
|
||||
fmt.Fprintf(writer, " description='%s'\n", strings.ReplaceAll(description, "'", "\\'"))
|
||||
fmt.Fprintf(writer, "))\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "# Example usage:\n")
|
||||
fmt.Fprintf(writer, "# from langchain.agents import initialize_agent, AgentType\n")
|
||||
fmt.Fprintf(writer, "# from langchain.llms import OpenAI\n")
|
||||
fmt.Fprintf(writer, "#\n")
|
||||
fmt.Fprintf(writer, "# llm = OpenAI(temperature=0)\n")
|
||||
fmt.Fprintf(writer, "# agent = initialize_agent(tools, llm, agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION)\n")
|
||||
fmt.Fprintf(writer, "# agent.run('Your query here')\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportOpenAPI exports tools in OpenAPI 3.0 format
|
||||
func exportOpenAPI(writer *os.File, services []*registry.Service, opts mcp.Options) error {
|
||||
spec := map[string]interface{}{
|
||||
"openapi": "3.0.0",
|
||||
"info": map[string]interface{}{
|
||||
"title": "Go Micro MCP Services",
|
||||
"description": "Auto-generated OpenAPI spec from MCP service discovery",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
"servers": []map[string]interface{}{
|
||||
{
|
||||
"url": "http://localhost:3000",
|
||||
"description": "MCP Gateway",
|
||||
},
|
||||
},
|
||||
"paths": make(map[string]interface{}),
|
||||
}
|
||||
|
||||
paths := spec["paths"].(map[string]interface{})
|
||||
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
toolName := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
|
||||
path := fmt.Sprintf("/mcp/call/%s", strings.ReplaceAll(toolName, ".", "/"))
|
||||
|
||||
description := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
description = desc
|
||||
}
|
||||
|
||||
operation := map[string]interface{}{
|
||||
"summary": toolName,
|
||||
"description": description,
|
||||
"operationId": strings.ReplaceAll(toolName, ".", "_"),
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Successful response",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add scope security if available
|
||||
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
|
||||
operation["security"] = []map[string]interface{}{
|
||||
{
|
||||
"bearerAuth": strings.Split(scopesStr, ","),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
paths[path] = map[string]interface{}{
|
||||
"post": operation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add security schemes
|
||||
spec["components"] = map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"bearerAuth": map[string]interface{}{
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(writer)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(spec)
|
||||
}
|
||||
|
||||
// exportJSON exports raw tool definitions as JSON
|
||||
func exportJSON(writer *os.File, services []*registry.Service, opts mcp.Options) error {
|
||||
var tools []map[string]interface{}
|
||||
|
||||
for _, svc := range services {
|
||||
fullSvcs, err := opts.Registry.GetService(svc.Name)
|
||||
if err != nil || len(fullSvcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ep := range fullSvcs[0].Endpoints {
|
||||
tool := map[string]interface{}{
|
||||
"name": fmt.Sprintf("%s.%s", svc.Name, ep.Name),
|
||||
"service": svc.Name,
|
||||
"endpoint": ep.Name,
|
||||
"metadata": ep.Metadata,
|
||||
}
|
||||
|
||||
if desc, ok := ep.Metadata["description"]; ok {
|
||||
tool["description"] = desc
|
||||
}
|
||||
|
||||
if example, ok := ep.Metadata["example"]; ok {
|
||||
tool["example"] = example
|
||||
}
|
||||
|
||||
if scopesStr, ok := ep.Metadata["scopes"]; ok && scopesStr != "" {
|
||||
tool["scopes"] = strings.Split(scopesStr, ",")
|
||||
}
|
||||
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(writer)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(map[string]interface{}{
|
||||
"tools": tools,
|
||||
"count": len(tools),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolName string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "simple two-part tool",
|
||||
toolName: "service.endpoint",
|
||||
want: []string{"service", "endpoint"},
|
||||
},
|
||||
{
|
||||
name: "three-part tool (service.Handler.Method)",
|
||||
toolName: "greeter.Greeter.Hello",
|
||||
want: []string{"greeter", "Greeter", "Hello"},
|
||||
},
|
||||
{
|
||||
name: "single part (invalid)",
|
||||
toolName: "service",
|
||||
want: []string{"service"},
|
||||
},
|
||||
{
|
||||
name: "four-part tool",
|
||||
toolName: "users.Users.Get.All",
|
||||
want: []string{"users", "Users", "Get", "All"},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
toolName: "",
|
||||
want: []string{""},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseTool(tt.toolName)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseTool(%q) = %v, want %v", tt.toolName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFormats(t *testing.T) {
|
||||
// Test that export formats are recognized
|
||||
formats := []string{"langchain", "openapi", "json"}
|
||||
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
// This is a basic test to ensure the format strings are defined
|
||||
// The actual export functions are tested through integration tests
|
||||
if format == "" {
|
||||
t.Error("export format should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFormats(t *testing.T) {
|
||||
// Test that docs formats are recognized
|
||||
formats := []string{"markdown", "json"}
|
||||
|
||||
for _, format := range formats {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
// This is a basic test to ensure the format strings are defined
|
||||
// The actual docs functions are tested through integration tests
|
||||
if format == "" {
|
||||
t.Error("docs format should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
-28
@@ -358,7 +358,7 @@ func Run(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Print startup banner
|
||||
printBanner(services, gw, !c.Bool("no-watch"))
|
||||
printBanner(services, gw, !c.Bool("no-watch"), c.String("mcp-address"))
|
||||
|
||||
// Setup signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -427,21 +427,25 @@ func processRunning(pidStr string) bool {
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool) {
|
||||
func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool, mcpAddr string) {
|
||||
fmt.Println()
|
||||
fmt.Println(" \033[1mMicro\033[0m")
|
||||
fmt.Println()
|
||||
fmt.Println(" ┌─────────────────────────────────────────────────────────────┐")
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println(" │ \033[1mMicro\033[0m │")
|
||||
fmt.Println(" │ │")
|
||||
|
||||
if gw != nil {
|
||||
fmt.Printf(" │ Web: \033[36mhttp://localhost%s\033[0m │\n", gw.Addr())
|
||||
fmt.Printf(" │ API: \033[36mhttp://localhost%s/api/{service}/{method}\033[0m │\n", gw.Addr())
|
||||
fmt.Printf(" │ Health: \033[36mhttp://localhost%s/health\033[0m │\n", gw.Addr())
|
||||
fmt.Printf(" Dashboard \033[36mhttp://localhost%s\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" API \033[36mhttp://localhost%s/api/{service}/{method}\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Agent \033[36mhttp://localhost%s/agent\033[0m\n", gw.Addr())
|
||||
fmt.Printf(" Health \033[36mhttp://localhost%s/health\033[0m\n", gw.Addr())
|
||||
if mcpAddr != "" {
|
||||
fmt.Printf(" MCP \033[36mhttp://localhost%s\033[0m\n", mcpAddr)
|
||||
fmt.Printf(" MCP Tools \033[36mhttp://localhost%s/mcp/tools\033[0m\n", mcpAddr)
|
||||
fmt.Printf(" WebSocket \033[36mws://localhost%s/mcp/ws\033[0m\n", mcpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println(" │ Services: │")
|
||||
fmt.Println()
|
||||
fmt.Println(" Services:")
|
||||
|
||||
for _, svc := range services {
|
||||
status := "\033[32m●\033[0m" // green dot
|
||||
@@ -449,30 +453,19 @@ func printBanner(services []*serviceProcess, gw *server.Gateway, watching bool)
|
||||
status = "\033[31m●\033[0m" // red dot
|
||||
}
|
||||
name := svc.name
|
||||
if len(name) > 20 {
|
||||
name = name[:17] + "..."
|
||||
if len(name) > 40 {
|
||||
name = name[:37] + "..."
|
||||
}
|
||||
fmt.Printf(" │ %s %-20s │\n", status, name)
|
||||
fmt.Printf(" %s %s\n", status, name)
|
||||
}
|
||||
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println()
|
||||
fmt.Println(" Auth: \033[32menabled\033[0m (admin / micro)")
|
||||
|
||||
if watching {
|
||||
fmt.Println(" │ \033[33mWatching for changes...\033[0m │")
|
||||
fmt.Println(" │ │")
|
||||
fmt.Println(" \033[33mWatching for changes...\033[0m")
|
||||
}
|
||||
|
||||
fmt.Println(" │ Auth: \033[32menabled\033[0m (admin / micro) │")
|
||||
fmt.Println(" │ │")
|
||||
|
||||
if gw != nil && len(services) > 0 {
|
||||
svc := services[0]
|
||||
fmt.Println(" │ Try: │")
|
||||
fmt.Printf(" │ \033[90mcurl -X POST http://localhost%s/api/%s/...\033[0m │\n", gw.Addr(), svc.name)
|
||||
fmt.Println(" │ │")
|
||||
}
|
||||
|
||||
fmt.Println(" └─────────────────────────────────────────────────────────────┘")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
|
||||
+57
-309
@@ -28,6 +28,9 @@ import (
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/cmd"
|
||||
codecBytes "go-micro.dev/v5/codec/bytes"
|
||||
"go-micro.dev/v5/ai"
|
||||
_ "go-micro.dev/v5/ai/anthropic"
|
||||
_ "go-micro.dev/v5/ai/openai"
|
||||
"go-micro.dev/v5/registry"
|
||||
"go-micro.dev/v5/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -58,7 +61,7 @@ type templates struct {
|
||||
authLogin *template.Template
|
||||
authUsers *template.Template
|
||||
playground *template.Template
|
||||
scopes *template.Template
|
||||
scopes *template.Template
|
||||
}
|
||||
type TemplateUser struct {
|
||||
ID string
|
||||
@@ -82,7 +85,7 @@ func parseTemplates() *templates {
|
||||
authLogin: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_login.html")),
|
||||
authUsers: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/auth_users.html")),
|
||||
playground: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/playground.html")),
|
||||
scopes: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/scopes.html")),
|
||||
scopes: template.Must(template.ParseFS(HTML, "web/templates/base.html", "web/templates/scopes.html")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,7 +609,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
}
|
||||
}
|
||||
apiKey := ""
|
||||
model := ""
|
||||
modelName := ""
|
||||
baseURL := ""
|
||||
provider := ""
|
||||
if settings != nil {
|
||||
@@ -614,7 +617,7 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
apiKey = v
|
||||
}
|
||||
if v := settings["model"]; v != "" {
|
||||
model = v
|
||||
modelName = v
|
||||
}
|
||||
if v := settings["base_url"]; v != "" {
|
||||
baseURL = v
|
||||
@@ -630,39 +633,12 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
|
||||
// Auto-detect provider if not explicitly set
|
||||
if provider == "" {
|
||||
if strings.Contains(baseURL, "anthropic") {
|
||||
provider = "anthropic"
|
||||
} else {
|
||||
provider = "openai"
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults based on provider
|
||||
if provider == "anthropic" {
|
||||
if model == "" {
|
||||
model = "claude-sonnet-4-20250514"
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com"
|
||||
}
|
||||
} else {
|
||||
if model == "" {
|
||||
model = "gpt-4o"
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
provider = ai.AutoDetectProvider(baseURL)
|
||||
}
|
||||
|
||||
// Discover tools from registry
|
||||
services, _ := registry.ListServices()
|
||||
type toolInfo struct {
|
||||
Name string // original dotted name (e.g. "greeter.Greeter.Hello")
|
||||
SafeName string // LLM-safe name (dots replaced with underscores)
|
||||
Description string
|
||||
Properties map[string]any
|
||||
}
|
||||
var discoveredTools []toolInfo
|
||||
var discoveredTools []ai.Tool
|
||||
// safeNameMap maps LLM-safe names back to original dotted names
|
||||
safeNameMap := map[string]string{}
|
||||
for _, svc := range services {
|
||||
@@ -689,11 +665,11 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
}
|
||||
}
|
||||
}
|
||||
discoveredTools = append(discoveredTools, toolInfo{
|
||||
Name: tName,
|
||||
SafeName: safeName,
|
||||
Description: desc,
|
||||
Properties: props,
|
||||
discoveredTools = append(discoveredTools, ai.Tool{
|
||||
Name: safeName,
|
||||
OriginalName: tName,
|
||||
Description: desc,
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -776,282 +752,54 @@ func registerHandlers(mux *http.ServeMux, tmpls *templates, storeInst store.Stor
|
||||
return rpcResult, string(rsp.Data)
|
||||
}
|
||||
|
||||
// callLLMAPI makes an HTTP request to the LLM provider
|
||||
callLLMAPI := func(url string, body []byte) ([]byte, error) {
|
||||
httpReq, err := http.NewRequestWithContext(r.Context(), "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if provider == "anthropic" {
|
||||
httpReq.Header.Set("x-api-key", apiKey)
|
||||
httpReq.Header.Set("anthropic-version", "2023-06-01")
|
||||
} else {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("LLM API error (%s): %s", resp.Status, string(respBody))
|
||||
}
|
||||
return respBody, nil
|
||||
// Create model with options
|
||||
var modelOpts []ai.Option
|
||||
modelOpts = append(modelOpts, ai.WithAPIKey(apiKey))
|
||||
if modelName != "" {
|
||||
modelOpts = append(modelOpts, ai.WithModel(modelName))
|
||||
}
|
||||
if baseURL != "" {
|
||||
modelOpts = append(modelOpts, ai.WithBaseURL(baseURL))
|
||||
}
|
||||
modelOpts = append(modelOpts, ai.WithToolHandler(executeToolCall))
|
||||
|
||||
m := ai.New(provider, modelOpts...)
|
||||
if m == nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to create model provider"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build request
|
||||
modelReq := &ai.Request{
|
||||
Prompt: req.Prompt,
|
||||
SystemPrompt: agentSystemPrompt,
|
||||
Tools: discoveredTools,
|
||||
}
|
||||
|
||||
// Generate response
|
||||
response, err := m.Generate(r.Context(), modelReq)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := map[string]any{}
|
||||
|
||||
if provider == "anthropic" {
|
||||
// --- Anthropic Messages API ---
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range discoveredTools {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.SafeName,
|
||||
"description": t.Description,
|
||||
"input_schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
if response.Reply != "" {
|
||||
result["reply"] = response.Reply
|
||||
}
|
||||
if len(response.ToolCalls) > 0 {
|
||||
var toolCalls []map[string]any
|
||||
for _, tc := range response.ToolCalls {
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"tool": tc.Name,
|
||||
"input": tc.Input,
|
||||
})
|
||||
}
|
||||
|
||||
anthropicReq := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"system": agentSystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
},
|
||||
}
|
||||
if len(anthropicTools) > 0 {
|
||||
anthropicReq["tools"] = anthropicTools
|
||||
}
|
||||
chatBody, _ := json.Marshal(anthropicReq)
|
||||
|
||||
apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages"
|
||||
respBody, err := callLLMAPI(apiURL, chatBody)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Anthropic response
|
||||
var anthropicResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &anthropicResp); err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to parse LLM response: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract text reply
|
||||
var replyParts []string
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
replyParts = append(replyParts, block.Text)
|
||||
}
|
||||
}
|
||||
if len(replyParts) > 0 {
|
||||
result["reply"] = strings.Join(replyParts, "\n")
|
||||
}
|
||||
|
||||
// Execute tool uses
|
||||
var toolUseBlocks []struct {
|
||||
ID string
|
||||
Name string
|
||||
Input map[string]any
|
||||
}
|
||||
for _, block := range anthropicResp.Content {
|
||||
if block.Type == "tool_use" {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(block.Input, &input); err != nil {
|
||||
log.Printf("[agent] failed to parse tool input: %v", err)
|
||||
input = map[string]any{}
|
||||
}
|
||||
toolUseBlocks = append(toolUseBlocks, struct {
|
||||
ID string
|
||||
Name string
|
||||
Input map[string]any
|
||||
}{ID: block.ID, Name: block.Name, Input: input})
|
||||
}
|
||||
}
|
||||
|
||||
if len(toolUseBlocks) > 0 {
|
||||
var toolCalls []map[string]any
|
||||
var toolResultBlocks []map[string]any
|
||||
|
||||
for _, tu := range toolUseBlocks {
|
||||
rpcResult, rpcContent := executeToolCall(tu.Name, tu.Input)
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"tool": tu.Name,
|
||||
"input": tu.Input,
|
||||
"result": rpcResult,
|
||||
})
|
||||
toolResultBlocks = append(toolResultBlocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tu.ID,
|
||||
"content": rpcContent,
|
||||
})
|
||||
}
|
||||
result["tool_calls"] = toolCalls
|
||||
|
||||
// Follow-up: send tool results back to Anthropic
|
||||
followUpReq := map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"system": agentSystemPrompt,
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": req.Prompt},
|
||||
{"role": "assistant", "content": anthropicResp.Content},
|
||||
{"role": "user", "content": toolResultBlocks},
|
||||
},
|
||||
}
|
||||
|
||||
followUpBody, _ := json.Marshal(followUpReq)
|
||||
if followUpRespBody, err := callLLMAPI(apiURL, followUpBody); err == nil {
|
||||
var followUpResp struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(followUpRespBody, &followUpResp) == nil {
|
||||
var answerParts []string
|
||||
for _, block := range followUpResp.Content {
|
||||
if block.Type == "text" && block.Text != "" {
|
||||
answerParts = append(answerParts, block.Text)
|
||||
}
|
||||
}
|
||||
if len(answerParts) > 0 {
|
||||
result["answer"] = strings.Join(answerParts, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --- OpenAI Chat Completions API ---
|
||||
var openaiTools []map[string]any
|
||||
for _, t := range discoveredTools {
|
||||
openaiTools = append(openaiTools, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.SafeName,
|
||||
"description": t.Description,
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": t.Properties,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": agentSystemPrompt},
|
||||
{"role": "user", "content": req.Prompt},
|
||||
}
|
||||
chatReq := map[string]any{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
if len(openaiTools) > 0 {
|
||||
chatReq["tools"] = openaiTools
|
||||
}
|
||||
chatBody, _ := json.Marshal(chatReq)
|
||||
|
||||
apiURL := strings.TrimRight(baseURL, "/") + "/v1/chat/completions"
|
||||
respBody, err := callLLMAPI(apiURL, chatBody)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to parse LLM response: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "No response from LLM"})
|
||||
return
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
if choice.Message.Content != "" {
|
||||
result["reply"] = choice.Message.Content
|
||||
}
|
||||
|
||||
// Execute any tool calls
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
var toolCalls []map[string]any
|
||||
followUpMessages := append(messages, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": choice.Message.Content,
|
||||
"tool_calls": choice.Message.ToolCalls,
|
||||
})
|
||||
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
log.Printf("[agent] failed to parse tool arguments: %v", err)
|
||||
}
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
rpcResult, rpcContent := executeToolCall(tc.Function.Name, input)
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"tool": tc.Function.Name,
|
||||
"input": input,
|
||||
"result": rpcResult,
|
||||
})
|
||||
followUpMessages = append(followUpMessages, map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.ID,
|
||||
"content": rpcContent,
|
||||
})
|
||||
}
|
||||
result["tool_calls"] = toolCalls
|
||||
|
||||
// Follow-up: send tool results back to LLM for a final answer
|
||||
followUpReq := map[string]any{
|
||||
"model": model,
|
||||
"messages": followUpMessages,
|
||||
}
|
||||
followUpBody, _ := json.Marshal(followUpReq)
|
||||
if followUpRespBody, err := callLLMAPI(apiURL, followUpBody); err == nil {
|
||||
var followUpChat struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal(followUpRespBody, &followUpChat) == nil && len(followUpChat.Choices) > 0 {
|
||||
result["answer"] = followUpChat.Choices[0].Message.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
result["tool_calls"] = toolCalls
|
||||
}
|
||||
if response.Answer != "" {
|
||||
result["answer"] = response.Answer
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(result)
|
||||
|
||||
@@ -1,39 +1,140 @@
|
||||
{{define "content"}}
|
||||
<h2>Agent</h2>
|
||||
<p>Chat with your microservices using AI. Configure a model API key in settings, then use the prompt to interact with your services.</p>
|
||||
<style>
|
||||
#agent-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 160px);
|
||||
max-height: 800px;
|
||||
}
|
||||
#agent-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1em 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.msg { padding: 0.7em 1em; border-radius: 8px; margin-bottom: 0.6em; line-height: 1.6; }
|
||||
.msg-user { background: #f0f4ff; border: 1px solid #d0d8f0; }
|
||||
.msg-user b { color: #336; }
|
||||
.msg-assistant { background: #fff; border: 1px solid #ddd; }
|
||||
.msg-assistant b { color: #333; }
|
||||
.msg-error { background: #fff5f5; border: 1px solid #e88; color: #a00; }
|
||||
.msg-thinking { background: #fafafa; border: 1px solid #e5e5e5; color: #888; font-style: italic; }
|
||||
.tool-call {
|
||||
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px;
|
||||
margin-bottom: 0.6em; font-size: 0.93em; overflow: hidden;
|
||||
}
|
||||
.tool-header {
|
||||
padding: 0.5em 1em; cursor: pointer; display: flex; align-items: center;
|
||||
gap: 0.5em; user-select: none; font-weight: 600; color: #555;
|
||||
}
|
||||
.tool-header:hover { background: #f0f0f0; }
|
||||
.tool-header .arrow { transition: transform 0.2s; display: inline-block; }
|
||||
.tool-header .arrow.open { transform: rotate(90deg); }
|
||||
.tool-body { padding: 0 1em 0.8em 1em; display: none; }
|
||||
.tool-body.open { display: block; }
|
||||
.tool-body pre {
|
||||
background: #f5f5f5; padding: 0.6em; border-radius: 4px;
|
||||
overflow-x: auto; font-size: 0.9em; margin: 0.4em 0;
|
||||
}
|
||||
.tool-body .tool-label { font-weight: 600; color: #666; font-size: 0.85em; margin-top: 0.5em; }
|
||||
.tool-status { font-size: 0.8em; font-weight: normal; margin-left: auto; }
|
||||
.tool-status.ok { color: #2a2; }
|
||||
.tool-status.err { color: #c00; }
|
||||
#prompt-bar {
|
||||
display: flex; gap: 0.5em; align-items: center;
|
||||
padding: 0.8em 0 0 0; border-top: 1px solid #eee;
|
||||
}
|
||||
#prompt-bar input {
|
||||
flex: 1; margin-bottom: 0; padding: 0.6em 0.8em;
|
||||
border: 1px solid #ccc; border-radius: 6px; font-size: 1em;
|
||||
}
|
||||
#prompt-bar button { padding: 0.6em 1.5em; border-radius: 6px; font-size: 1em; white-space: nowrap; }
|
||||
#prompt-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.tools-bar {
|
||||
display: flex; gap: 0.5em; align-items: center;
|
||||
padding: 0.5em 0; font-size: 0.85em; color: #888;
|
||||
}
|
||||
.tools-bar .tool-count { font-weight: 600; color: #555; }
|
||||
.settings-toggle {
|
||||
background: none; border: 1px solid #ddd; border-radius: 6px;
|
||||
padding: 0.3em 0.8em; font-size: 0.85em; cursor: pointer; color: #555;
|
||||
}
|
||||
.settings-toggle:hover { background: #f5f5f5; }
|
||||
#settings-panel {
|
||||
display: none; background: #fafafa; border: 1px solid #e5e5e5;
|
||||
border-radius: 8px; padding: 1em 1.2em; margin-bottom: 1em;
|
||||
}
|
||||
#settings-panel.open { display: block; }
|
||||
#settings-panel label { display: block; font-weight: 600; margin-top: 0.6em; font-size: 0.9em; }
|
||||
#settings-panel input, #settings-panel select {
|
||||
width: 100%; padding: 0.4em 0.6em; font-size: 0.9em;
|
||||
border: 1px solid #ddd; border-radius: 4px; margin-top: 0.2em;
|
||||
}
|
||||
#settings-panel .settings-row { display: flex; gap: 1em; }
|
||||
#settings-panel .settings-row > div { flex: 1; }
|
||||
.clear-btn {
|
||||
background: none; border: none; color: #999; cursor: pointer;
|
||||
font-size: 0.85em; padding: 0.3em 0.5em;
|
||||
}
|
||||
.clear-btn:hover { color: #c00; }
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100%; color: #aaa; text-align: center;
|
||||
}
|
||||
.empty-state .icon { font-size: 3em; margin-bottom: 0.3em; }
|
||||
.empty-state p { margin: 0.3em 0; max-width: 400px; }
|
||||
</style>
|
||||
|
||||
<h3>Prompt</h3>
|
||||
<div id="agent-messages"></div>
|
||||
<form onsubmit="return false;" style="display:flex; gap:0.5em; align-items:flex-end;">
|
||||
<input type="text" id="prompt-input" placeholder="e.g. List all users, Create a blog post..." style="flex:1; margin-bottom:0;">
|
||||
<button id="prompt-btn" onclick="sendPrompt()">Send</button>
|
||||
</form>
|
||||
<div id="agent-chat">
|
||||
<div class="tools-bar">
|
||||
<span>Tools: <span class="tool-count" id="tool-count">...</span></span>
|
||||
<button class="settings-toggle" onclick="toggleSettings()">Settings</button>
|
||||
<button class="clear-btn" onclick="clearChat()">Clear chat</button>
|
||||
</div>
|
||||
|
||||
<h3>Settings</h3>
|
||||
<form id="settings-form" onsubmit="return false;">
|
||||
<label style="display:block; font-weight:600;">Provider</label>
|
||||
<select id="provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
<label style="display:block; font-weight:600;">Model API Key</label>
|
||||
<input type="password" id="api-key" placeholder="sk-... or API key for your model provider">
|
||||
<label style="display:block; font-weight:600;">Model (optional)</label>
|
||||
<input type="text" id="model-name" placeholder="e.g. gpt-4o or claude-sonnet-4-20250514">
|
||||
<label style="display:block; font-weight:600;">Base URL (optional)</label>
|
||||
<input type="text" id="base-url" placeholder="Leave blank for default">
|
||||
<button onclick="saveSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left:0.5em; color:#888;"></span>
|
||||
</form>
|
||||
<div id="settings-panel">
|
||||
<div class="settings-row">
|
||||
<div>
|
||||
<label>Provider</label>
|
||||
<select id="provider">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Model (optional)</label>
|
||||
<input type="text" id="model-name" placeholder="e.g. gpt-4o, claude-sonnet-4-20250514">
|
||||
</div>
|
||||
</div>
|
||||
<label>API Key</label>
|
||||
<input type="password" id="api-key" placeholder="sk-... or API key">
|
||||
<label>Base URL (optional)</label>
|
||||
<input type="text" id="base-url" placeholder="Leave blank for default">
|
||||
<div style="margin-top:0.8em; display:flex; gap:0.5em; align-items:center;">
|
||||
<button onclick="saveSettings()" style="padding:0.4em 1em; font-size:0.9em;">Save</button>
|
||||
<span id="settings-status" style="color:#888; font-size:0.85em;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Available Tools</h3>
|
||||
<div id="tools-list">
|
||||
<p style="color:#888;">Loading tools...</p>
|
||||
<div id="agent-messages">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="icon">🤖</div>
|
||||
<p><b>Chat with your services</b></p>
|
||||
<p>Ask the agent to interact with your microservices. It will discover and call the right tools automatically.</p>
|
||||
<p id="empty-tools" style="font-size:0.85em; color:#bbb; margin-top:0.8em;"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="prompt-bar">
|
||||
<input type="text" id="prompt-input" placeholder="Ask the agent to call your services..." autofocus>
|
||||
<button id="prompt-btn" onclick="sendPrompt()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var tools = [];
|
||||
var toolCount = document.getElementById('tool-count');
|
||||
|
||||
loadSettings();
|
||||
loadTools();
|
||||
@@ -46,10 +147,20 @@
|
||||
if (data.api_key) document.getElementById('api-key').value = data.api_key;
|
||||
if (data.model) document.getElementById('model-name').value = data.model;
|
||||
if (data.base_url) document.getElementById('base-url').value = data.base_url;
|
||||
// Auto-show settings if no API key configured
|
||||
if (!data.api_key) {
|
||||
document.getElementById('settings-panel').classList.add('open');
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
.catch(function() {
|
||||
document.getElementById('settings-panel').classList.add('open');
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSettings = function() {
|
||||
document.getElementById('settings-panel').classList.toggle('open');
|
||||
};
|
||||
|
||||
window.saveSettings = function() {
|
||||
var status = document.getElementById('settings-status');
|
||||
status.textContent = 'Saving...';
|
||||
@@ -64,7 +175,10 @@
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() { status.textContent = 'Saved'; })
|
||||
.then(function() {
|
||||
status.textContent = 'Saved';
|
||||
setTimeout(function() { status.textContent = ''; }, 2000);
|
||||
})
|
||||
.catch(function(err) { status.textContent = 'Error: ' + err; });
|
||||
};
|
||||
|
||||
@@ -73,28 +187,30 @@
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
tools = data.tools || [];
|
||||
renderTools();
|
||||
toolCount.textContent = tools.length;
|
||||
var emptyTools = document.getElementById('empty-tools');
|
||||
if (tools.length > 0) {
|
||||
var names = tools.slice(0, 5).map(function(t) { return t.name; });
|
||||
var suffix = tools.length > 5 ? ' and ' + (tools.length - 5) + ' more' : '';
|
||||
emptyTools.textContent = 'Available: ' + names.join(', ') + suffix;
|
||||
} else {
|
||||
emptyTools.textContent = 'No tools found. Start some services first.';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('tools-list').innerHTML = '<p style="color:#c00;">Failed to load tools: ' + err + '</p>';
|
||||
.catch(function() {
|
||||
toolCount.textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTools() {
|
||||
var el = document.getElementById('tools-list');
|
||||
if (tools.length === 0) {
|
||||
el.innerHTML = '<p style="color:#888;">No tools available. Start some services and they will appear here.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table><thead><tr><th>Tool</th><th>Description</th></tr></thead><tbody>';
|
||||
for (var i = 0; i < tools.length; i++) {
|
||||
var t = tools[i];
|
||||
html += '<tr><td><code>' + escapeHtml(t.name) + '</code></td>';
|
||||
html += '<td>' + escapeHtml(t.description || '') + '</td></tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
window.clearChat = function() {
|
||||
var container = document.getElementById('agent-messages');
|
||||
container.innerHTML = '';
|
||||
var emptyState = document.createElement('div');
|
||||
emptyState.className = 'empty-state';
|
||||
emptyState.id = 'empty-state';
|
||||
emptyState.innerHTML = '<div class="icon">🤖</div><p><b>Chat with your services</b></p><p>Ask the agent to interact with your microservices.</p>';
|
||||
container.appendChild(emptyState);
|
||||
};
|
||||
|
||||
window.sendPrompt = function() {
|
||||
var input = document.getElementById('prompt-input');
|
||||
@@ -102,11 +218,21 @@
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
|
||||
// Remove empty state on first message
|
||||
var emptyState = document.getElementById('empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
addMessage('user', escapeHtml(text));
|
||||
|
||||
// Show thinking indicator
|
||||
var thinkingId = 'thinking-' + Date.now();
|
||||
addMessage('thinking', 'Agent is thinking...', thinkingId);
|
||||
|
||||
var btn = document.getElementById('prompt-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
btn.textContent = 'Sending...';
|
||||
|
||||
var startTime = Date.now();
|
||||
|
||||
fetch('/api/agent/prompt', {
|
||||
method: 'POST',
|
||||
@@ -115,8 +241,15 @@
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send';
|
||||
input.focus();
|
||||
|
||||
// Remove thinking indicator
|
||||
var thinking = document.getElementById(thinkingId);
|
||||
if (thinking) thinking.remove();
|
||||
|
||||
if (data.error) {
|
||||
addMessage('error', escapeHtml(data.error));
|
||||
return;
|
||||
@@ -127,9 +260,7 @@
|
||||
if (data.tool_calls && data.tool_calls.length > 0) {
|
||||
for (var i = 0; i < data.tool_calls.length; i++) {
|
||||
var tc = data.tool_calls[i];
|
||||
addMessage('tool', '<b>Tool:</b> <code>' + escapeHtml(tc.tool) + '</code>' +
|
||||
'<pre>' + escapeHtml(JSON.stringify(tc.input, null, 2)) + '</pre>' +
|
||||
'<b>Result:</b><pre>' + escapeHtml(JSON.stringify(tc.result, null, 2)) + '</pre>');
|
||||
addToolCall(tc, elapsed);
|
||||
}
|
||||
}
|
||||
if (data.answer) {
|
||||
@@ -139,35 +270,58 @@
|
||||
.catch(function(err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send';
|
||||
input.focus();
|
||||
var thinking = document.getElementById(thinkingId);
|
||||
if (thinking) thinking.remove();
|
||||
addMessage('error', 'Error: ' + escapeHtml(String(err)));
|
||||
});
|
||||
};
|
||||
|
||||
function addToolCall(tc, elapsed) {
|
||||
var container = document.getElementById('agent-messages');
|
||||
var div = document.createElement('div');
|
||||
div.className = 'tool-call';
|
||||
|
||||
var hasError = tc.result && tc.result.error;
|
||||
var statusClass = hasError ? 'err' : 'ok';
|
||||
var statusText = hasError ? 'error' : elapsed + 's';
|
||||
|
||||
var inputJson = JSON.stringify(tc.input, null, 2);
|
||||
var resultJson = JSON.stringify(tc.result, null, 2);
|
||||
|
||||
div.innerHTML =
|
||||
'<div class="tool-header" onclick="this.querySelector(\'.arrow\').classList.toggle(\'open\'); this.nextElementSibling.classList.toggle(\'open\');">' +
|
||||
'<span class="arrow">▶</span> ' +
|
||||
'<code>' + escapeHtml(tc.tool) + '</code>' +
|
||||
'<span class="tool-status ' + statusClass + '">' + statusText + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="tool-body">' +
|
||||
'<div class="tool-label">Input</div>' +
|
||||
'<pre>' + escapeHtml(inputJson) + '</pre>' +
|
||||
'<div class="tool-label">Result</div>' +
|
||||
'<pre>' + escapeHtml(resultJson) + '</pre>' +
|
||||
'</div>';
|
||||
|
||||
container.appendChild(div);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
document.getElementById('prompt-input').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); window.sendPrompt(); }
|
||||
});
|
||||
|
||||
function addMessage(type, html) {
|
||||
function addMessage(type, html, id) {
|
||||
var container = document.getElementById('agent-messages');
|
||||
var div = document.createElement('div');
|
||||
div.style.cssText = 'padding:0.8em 1em; border-radius:7px; margin-bottom:0.8em; line-height:1.6;';
|
||||
div.className = 'msg msg-' + type;
|
||||
if (id) div.id = id;
|
||||
if (type === 'user') {
|
||||
div.style.background = '#f7f7f7';
|
||||
div.style.border = '1px solid #eee';
|
||||
div.innerHTML = '<b>You:</b> ' + html;
|
||||
div.innerHTML = '<b>You</b><br>' + html;
|
||||
} else if (type === 'assistant' || type === 'answer') {
|
||||
div.style.background = '#fff';
|
||||
div.style.border = '1px solid #ddd';
|
||||
div.innerHTML = '<b>Agent:</b> ' + html;
|
||||
} else if (type === 'tool') {
|
||||
div.style.background = '#fafafa';
|
||||
div.style.border = '1px solid #e0e0e0';
|
||||
div.style.fontSize = '0.95em';
|
||||
div.innerHTML = '<b>Agent</b><br>' + html;
|
||||
} else if (type === 'thinking') {
|
||||
div.innerHTML = html;
|
||||
} else if (type === 'error') {
|
||||
div.style.background = '#fff';
|
||||
div.style.border = '1px solid #c00';
|
||||
div.style.color = '#c00';
|
||||
div.innerHTML = html;
|
||||
}
|
||||
container.appendChild(div);
|
||||
|
||||
@@ -84,91 +84,78 @@ func Version(v string) Option {
|
||||
func Broker(b *broker.Broker) Option {
|
||||
return func(o *Options) {
|
||||
o.Broker = b
|
||||
broker.DefaultBroker = *b
|
||||
}
|
||||
}
|
||||
|
||||
func Cache(c *cache.Cache) Option {
|
||||
return func(o *Options) {
|
||||
o.Cache = c
|
||||
cache.DefaultCache = *c
|
||||
}
|
||||
}
|
||||
|
||||
func Config(c *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = c
|
||||
config.DefaultConfig = *c
|
||||
}
|
||||
}
|
||||
|
||||
func Selector(s *selector.Selector) Option {
|
||||
return func(o *Options) {
|
||||
o.Selector = s
|
||||
selector.DefaultSelector = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Registry(r *registry.Registry) Option {
|
||||
return func(o *Options) {
|
||||
o.Registry = r
|
||||
registry.DefaultRegistry = *r
|
||||
}
|
||||
}
|
||||
|
||||
func Transport(t *transport.Transport) Option {
|
||||
return func(o *Options) {
|
||||
o.Transport = t
|
||||
transport.DefaultTransport = *t
|
||||
}
|
||||
}
|
||||
|
||||
func Client(c *client.Client) Option {
|
||||
return func(o *Options) {
|
||||
o.Client = c
|
||||
client.DefaultClient = *c
|
||||
}
|
||||
}
|
||||
|
||||
func Server(s *server.Server) Option {
|
||||
return func(o *Options) {
|
||||
o.Server = s
|
||||
server.DefaultServer = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Store(s *store.Store) Option {
|
||||
return func(o *Options) {
|
||||
o.Store = s
|
||||
store.DefaultStore = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Stream(s *events.Stream) Option {
|
||||
return func(o *Options) {
|
||||
o.Stream = s
|
||||
events.DefaultStream = *s
|
||||
}
|
||||
}
|
||||
|
||||
func Tracer(t *trace.Tracer) Option {
|
||||
return func(o *Options) {
|
||||
o.Tracer = t
|
||||
trace.DefaultTracer = *t
|
||||
}
|
||||
}
|
||||
|
||||
func Auth(a *auth.Auth) Option {
|
||||
return func(o *Options) {
|
||||
o.Auth = a
|
||||
auth.DefaultAuth = *a
|
||||
}
|
||||
}
|
||||
|
||||
func Profile(p *profile.Profile) Option {
|
||||
return func(o *Options) {
|
||||
o.DebugProfile = p
|
||||
profile.DefaultProfile = *p
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Code generated by protoc-gen-micro. DO NOT EDIT.
|
||||
// source: user.proto
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
proto "google.golang.org/protobuf/proto"
|
||||
math "math"
|
||||
)
|
||||
|
||||
import (
|
||||
context "context"
|
||||
client "go-micro.dev/v5/client"
|
||||
server "go-micro.dev/v5/server"
|
||||
model "go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ = proto.Marshal
|
||||
var _ = fmt.Errorf
|
||||
var _ = math.Inf
|
||||
|
||||
// Reference imports to suppress errors if they are not otherwise used.
|
||||
var _ context.Context
|
||||
var _ client.Option
|
||||
var _ server.Option
|
||||
var _ model.Model
|
||||
|
||||
// Client API for UserService service
|
||||
|
||||
type UserServiceService interface {
|
||||
Create(ctx context.Context, in *CreateUserRequest, opts ...client.CallOption) (*CreateUserResponse, error)
|
||||
Get(ctx context.Context, in *GetUserRequest, opts ...client.CallOption) (*GetUserResponse, error)
|
||||
Delete(ctx context.Context, in *DeleteUserRequest, opts ...client.CallOption) (*DeleteUserResponse, error)
|
||||
}
|
||||
|
||||
type userServiceService struct {
|
||||
c client.Client
|
||||
name string
|
||||
}
|
||||
|
||||
func NewUserServiceService(name string, c client.Client) UserServiceService {
|
||||
return &userServiceService{
|
||||
c: c,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *userServiceService) Create(ctx context.Context, in *CreateUserRequest, opts ...client.CallOption) (*CreateUserResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "UserService.Create", in)
|
||||
out := new(CreateUserResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceService) Get(ctx context.Context, in *GetUserRequest, opts ...client.CallOption) (*GetUserResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "UserService.Get", in)
|
||||
out := new(GetUserResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceService) Delete(ctx context.Context, in *DeleteUserRequest, opts ...client.CallOption) (*DeleteUserResponse, error) {
|
||||
req := c.c.NewRequest(c.name, "UserService.Delete", in)
|
||||
out := new(DeleteUserResponse)
|
||||
err := c.c.Call(ctx, req, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Server API for UserService service
|
||||
|
||||
type UserServiceHandler interface {
|
||||
Create(context.Context, *CreateUserRequest, *CreateUserResponse) error
|
||||
Get(context.Context, *GetUserRequest, *GetUserResponse) error
|
||||
Delete(context.Context, *DeleteUserRequest, *DeleteUserResponse) error
|
||||
}
|
||||
|
||||
func RegisterUserServiceHandler(s server.Server, hdlr UserServiceHandler, opts ...server.HandlerOption) error {
|
||||
type userService interface {
|
||||
Create(ctx context.Context, in *CreateUserRequest, out *CreateUserResponse) error
|
||||
Get(ctx context.Context, in *GetUserRequest, out *GetUserResponse) error
|
||||
Delete(ctx context.Context, in *DeleteUserRequest, out *DeleteUserResponse) error
|
||||
}
|
||||
type UserService struct {
|
||||
userService
|
||||
}
|
||||
h := &userServiceHandler{hdlr}
|
||||
return s.Handle(s.NewHandler(&UserService{h}, opts...))
|
||||
}
|
||||
|
||||
type userServiceHandler struct {
|
||||
UserServiceHandler
|
||||
}
|
||||
|
||||
func (h *userServiceHandler) Create(ctx context.Context, in *CreateUserRequest, out *CreateUserResponse) error {
|
||||
return h.UserServiceHandler.Create(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *userServiceHandler) Get(ctx context.Context, in *GetUserRequest, out *GetUserResponse) error {
|
||||
return h.UserServiceHandler.Get(ctx, in, out)
|
||||
}
|
||||
|
||||
func (h *userServiceHandler) Delete(ctx context.Context, in *DeleteUserRequest, out *DeleteUserResponse) error {
|
||||
return h.UserServiceHandler.Delete(ctx, in, out)
|
||||
}
|
||||
|
||||
// UserModel is a model struct generated from User.
|
||||
// Use NewUserModel to create a typed table backed by any model.Model.
|
||||
type UserModel struct {
|
||||
Id string `json:"id" model:"key"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Age int32 `json:"age"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// RegisterUserModel registers the UserModel table with the given model backend.
|
||||
func RegisterUserModel(db model.Model) error {
|
||||
return db.Register(&UserModel{}, model.WithTable("users"))
|
||||
}
|
||||
|
||||
// UserModelFromProto converts a User proto message to a UserModel.
|
||||
func UserModelFromProto(p *User) *UserModel {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserModel{
|
||||
Id: p.GetId(),
|
||||
Name: p.GetName(),
|
||||
Email: p.GetEmail(),
|
||||
Age: p.GetAge(),
|
||||
Status: p.GetStatus(),
|
||||
}
|
||||
}
|
||||
|
||||
// ToProto converts a UserModel to a User proto message.
|
||||
func (m *UserModel) ToProto() *User {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &User{
|
||||
Id: m.Id,
|
||||
Name: m.Name,
|
||||
Email: m.Email,
|
||||
Age: m.Age,
|
||||
Status: m.Status,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option go_package = "../user";
|
||||
|
||||
// UserService manages user accounts.
|
||||
service UserService {
|
||||
rpc Create(CreateUserRequest) returns (CreateUserResponse) {}
|
||||
rpc Get(GetUserRequest) returns (GetUserResponse) {}
|
||||
rpc Delete(DeleteUserRequest) returns (DeleteUserResponse) {}
|
||||
}
|
||||
|
||||
// @model
|
||||
message User {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
int32 age = 4;
|
||||
string status = 5;
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message CreateUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message GetUserRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message DeleteUserRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeleteUserResponse {}
|
||||
@@ -1193,6 +1193,15 @@ func (g *Generator) PrintComments(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetComments returns the raw leading comment text for the given path, if any.
|
||||
func (g *Generator) GetComments(path string) (string, bool) {
|
||||
loc, ok := g.file.comments[path]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return loc.GetLeadingComments(), true
|
||||
}
|
||||
|
||||
// makeComments generates the comment string for the field, no "\n" at the end
|
||||
func (g *Generator) makeComments(path string) (string, bool) {
|
||||
loc, ok := g.file.comments[path]
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
contextPkgPath = "context"
|
||||
clientPkgPath = "go-micro.dev/v5/client"
|
||||
serverPkgPath = "go-micro.dev/v5/server"
|
||||
modelPkgPath = "go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -42,6 +43,7 @@ var (
|
||||
contextPkg string
|
||||
clientPkg string
|
||||
serverPkg string
|
||||
modelPkg string
|
||||
pkgImports map[generator.GoPackageName]bool
|
||||
)
|
||||
|
||||
@@ -51,6 +53,7 @@ func (g *micro) Init(gen *generator.Generator) {
|
||||
contextPkg = generator.RegisterUniquePackageName("context", nil)
|
||||
clientPkg = generator.RegisterUniquePackageName("client", nil)
|
||||
serverPkg = generator.RegisterUniquePackageName("server", nil)
|
||||
modelPkg = generator.RegisterUniquePackageName("model", nil)
|
||||
}
|
||||
|
||||
// Given a type name defined in a .proto, return its object.
|
||||
@@ -70,29 +73,66 @@ func (g *micro) P(args ...interface{}) { g.gen.P(args...) }
|
||||
|
||||
// Generate generates code for the services in the given file.
|
||||
func (g *micro) Generate(file *generator.FileDescriptor) {
|
||||
if len(file.FileDescriptorProto.Service) == 0 {
|
||||
// Check if any messages have @model annotation
|
||||
hasModels := false
|
||||
for i := range file.FileDescriptorProto.MessageType {
|
||||
if g.isModelMessage(i) {
|
||||
hasModels = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(file.FileDescriptorProto.Service) == 0 && !hasModels {
|
||||
return
|
||||
}
|
||||
|
||||
g.P("// Reference imports to suppress errors if they are not otherwise used.")
|
||||
g.P("var _ ", contextPkg, ".Context")
|
||||
g.P("var _ ", clientPkg, ".Option")
|
||||
g.P("var _ ", serverPkg, ".Option")
|
||||
if len(file.FileDescriptorProto.Service) > 0 {
|
||||
g.P("var _ ", clientPkg, ".Option")
|
||||
g.P("var _ ", serverPkg, ".Option")
|
||||
}
|
||||
if hasModels {
|
||||
g.P("var _ ", modelPkg, ".Database")
|
||||
}
|
||||
g.P()
|
||||
|
||||
for i, service := range file.FileDescriptorProto.Service {
|
||||
g.generateService(file, service, i)
|
||||
}
|
||||
|
||||
// Generate model structs for @model annotated messages
|
||||
for i, msg := range file.FileDescriptorProto.MessageType {
|
||||
if g.isModelMessage(i) {
|
||||
g.generateModel(msg, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateImports generates the import declaration for this file.
|
||||
func (g *micro) GenerateImports(file *generator.FileDescriptor, imports map[generator.GoImportPath]generator.GoPackageName) {
|
||||
if len(file.FileDescriptorProto.Service) == 0 {
|
||||
hasServices := len(file.FileDescriptorProto.Service) > 0
|
||||
hasModels := false
|
||||
for i := range file.FileDescriptorProto.MessageType {
|
||||
if g.isModelMessage(i) {
|
||||
hasModels = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasServices && !hasModels {
|
||||
return
|
||||
}
|
||||
|
||||
g.P("import (")
|
||||
g.P(contextPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, contextPkgPath)))
|
||||
g.P(clientPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, clientPkgPath)))
|
||||
g.P(serverPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, serverPkgPath)))
|
||||
if hasServices {
|
||||
g.P(clientPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, clientPkgPath)))
|
||||
g.P(serverPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, serverPkgPath)))
|
||||
}
|
||||
if hasModels {
|
||||
g.P(modelPkg, " ", strconv.Quote(path.Join(g.gen.ImportPrefix, modelPkgPath)))
|
||||
}
|
||||
g.P(")")
|
||||
g.P()
|
||||
|
||||
@@ -529,3 +569,187 @@ func (g *micro) generateServerMethod(servName string, method *pb.MethodDescripto
|
||||
|
||||
return hname
|
||||
}
|
||||
|
||||
// isModelMessage checks if the message at the given index has a // @model annotation.
|
||||
// Path "4,<index>" refers to message_type[index] in FileDescriptorProto.
|
||||
func (g *micro) isModelMessage(msgIndex int) bool {
|
||||
commentPath := fmt.Sprintf("4,%d", msgIndex)
|
||||
comment, ok := g.gen.GetComments(commentPath)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(comment, "@model")
|
||||
}
|
||||
|
||||
// parseModelOptions extracts options from the @model annotation comment.
|
||||
// Supports: @model, @model(table=my_table), @model(key=custom_id)
|
||||
func parseModelOptions(comment string) (table string, key string) {
|
||||
idx := strings.Index(comment, "@model")
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
rest := comment[idx+len("@model"):]
|
||||
rest = strings.TrimSpace(rest)
|
||||
if !strings.HasPrefix(rest, "(") {
|
||||
return "", ""
|
||||
}
|
||||
end := strings.Index(rest, ")")
|
||||
if end < 0 {
|
||||
return "", ""
|
||||
}
|
||||
opts := rest[1:end]
|
||||
for _, part := range strings.Split(opts, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(kv[0]) {
|
||||
case "table":
|
||||
table = strings.TrimSpace(kv[1])
|
||||
case "key":
|
||||
key = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
return table, key
|
||||
}
|
||||
|
||||
// protoFieldGoType returns the Go type string for a proto field for use in model structs.
|
||||
// Only supports scalar types (no nested messages or enums in model structs).
|
||||
func protoFieldGoType(field *pb.FieldDescriptorProto) string {
|
||||
switch field.GetType() {
|
||||
case pb.FieldDescriptorProto_TYPE_DOUBLE:
|
||||
return "float64"
|
||||
case pb.FieldDescriptorProto_TYPE_FLOAT:
|
||||
return "float32"
|
||||
case pb.FieldDescriptorProto_TYPE_INT64, pb.FieldDescriptorProto_TYPE_SINT64, pb.FieldDescriptorProto_TYPE_SFIXED64:
|
||||
return "int64"
|
||||
case pb.FieldDescriptorProto_TYPE_UINT64, pb.FieldDescriptorProto_TYPE_FIXED64:
|
||||
return "uint64"
|
||||
case pb.FieldDescriptorProto_TYPE_INT32, pb.FieldDescriptorProto_TYPE_SINT32, pb.FieldDescriptorProto_TYPE_SFIXED32:
|
||||
return "int32"
|
||||
case pb.FieldDescriptorProto_TYPE_UINT32, pb.FieldDescriptorProto_TYPE_FIXED32:
|
||||
return "uint32"
|
||||
case pb.FieldDescriptorProto_TYPE_BOOL:
|
||||
return "bool"
|
||||
case pb.FieldDescriptorProto_TYPE_STRING:
|
||||
return "string"
|
||||
case pb.FieldDescriptorProto_TYPE_BYTES:
|
||||
return "[]byte"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// generateModel generates the model struct, factory, and proto conversion for a message.
|
||||
func (g *micro) generateModel(msg *pb.DescriptorProto, msgIndex int) {
|
||||
msgName := generator.CamelCase(msg.GetName())
|
||||
modelName := msgName + "Model"
|
||||
|
||||
// Parse options from comment
|
||||
commentPath := fmt.Sprintf("4,%d", msgIndex)
|
||||
comment, _ := g.gen.GetComments(commentPath)
|
||||
tableName, keyField := parseModelOptions(comment)
|
||||
|
||||
// Default table: lowercase message name + "s"
|
||||
if tableName == "" {
|
||||
tableName = strings.ToLower(msg.GetName()) + "s"
|
||||
}
|
||||
|
||||
// Default key: first field, or "id" if a field named "id" exists
|
||||
if keyField == "" {
|
||||
for _, field := range msg.Field {
|
||||
if field.GetName() == "id" {
|
||||
keyField = "id"
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyField == "" && len(msg.Field) > 0 {
|
||||
keyField = msg.Field[0].GetName()
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to scalar fields only (skip nested messages, maps, oneofs)
|
||||
type modelField struct {
|
||||
goName string
|
||||
jsonName string
|
||||
goType string
|
||||
isKey bool
|
||||
proto *pb.FieldDescriptorProto
|
||||
}
|
||||
var fields []modelField
|
||||
for _, field := range msg.Field {
|
||||
ft := field.GetType()
|
||||
// Skip message and enum types (not directly storable as scalars)
|
||||
if ft == pb.FieldDescriptorProto_TYPE_MESSAGE || ft == pb.FieldDescriptorProto_TYPE_GROUP {
|
||||
continue
|
||||
}
|
||||
// Skip repeated fields (slices aren't directly storable)
|
||||
if field.GetLabel() == pb.FieldDescriptorProto_LABEL_REPEATED {
|
||||
continue
|
||||
}
|
||||
goName := generator.CamelCase(field.GetName())
|
||||
jsonName := field.GetJsonName()
|
||||
if jsonName == "" {
|
||||
jsonName = field.GetName()
|
||||
}
|
||||
fields = append(fields, modelField{
|
||||
goName: goName,
|
||||
jsonName: jsonName,
|
||||
goType: protoFieldGoType(field),
|
||||
isKey: field.GetName() == keyField,
|
||||
proto: field,
|
||||
})
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate model struct
|
||||
g.P()
|
||||
g.P("// ", modelName, " is a model struct generated from ", msgName, ".")
|
||||
g.P("// Use New", modelName, " to create a typed table backed by any model.Model.")
|
||||
g.P("type ", modelName, " struct {")
|
||||
for _, f := range fields {
|
||||
tags := fmt.Sprintf("`json:%q", f.jsonName)
|
||||
if f.isKey {
|
||||
tags += ` model:"key"`
|
||||
}
|
||||
tags += "`"
|
||||
g.P(f.goName, " ", f.goType, " ", tags)
|
||||
}
|
||||
g.P("}")
|
||||
g.P()
|
||||
|
||||
// Generate Register helper: RegisterXModel(db) registers the model with the given backend.
|
||||
g.P("// Register", modelName, " registers the ", modelName, " table with the given model backend.")
|
||||
g.P("func Register", modelName, "(db ", modelPkg, ".Model) error {")
|
||||
g.P("return db.Register(&", modelName, "{}, ", modelPkg, `.WithTable("`, tableName, `"))`)
|
||||
g.P("}")
|
||||
g.P()
|
||||
|
||||
// Generate FromProto: XModelFromProto(*X) *XModel
|
||||
g.P("// ", modelName, "FromProto converts a ", msgName, " proto message to a ", modelName, ".")
|
||||
g.P("func ", modelName, "FromProto(p *", msgName, ") *", modelName, " {")
|
||||
g.P("if p == nil { return nil }")
|
||||
g.P("return &", modelName, "{")
|
||||
for _, f := range fields {
|
||||
getter := "Get" + f.goName
|
||||
g.P(f.goName, ": p.", getter, "(),")
|
||||
}
|
||||
g.P("}")
|
||||
g.P("}")
|
||||
g.P()
|
||||
|
||||
// Generate ToProto: (*XModel).ToProto() *X
|
||||
g.P("// ToProto converts a ", modelName, " to a ", msgName, " proto message.")
|
||||
g.P("func (m *", modelName, ") ToProto() *", msgName, " {")
|
||||
g.P("if m == nil { return nil }")
|
||||
g.P("return &", msgName, "{")
|
||||
for _, f := range fields {
|
||||
g.P(f.goName, ": m.", f.goName, ",")
|
||||
}
|
||||
g.P("}")
|
||||
g.P("}")
|
||||
g.P()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package micro
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseModelOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
comment string
|
||||
wantTable string
|
||||
wantKey string
|
||||
}{
|
||||
{" @model\n", "", ""},
|
||||
{" @model(table=app_users)\n", "app_users", ""},
|
||||
{" @model(key=user_id)\n", "", "user_id"},
|
||||
{" @model(table=users, key=user_id)\n", "users", "user_id"},
|
||||
{" some description\n @model(table=items)\n", "items", ""},
|
||||
{" no annotation here\n", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
table, key := parseModelOptions(tt.comment)
|
||||
if table != tt.wantTable {
|
||||
t.Errorf("parseModelOptions(%q): table = %q, want %q", tt.comment, table, tt.wantTable)
|
||||
}
|
||||
if key != tt.wantKey {
|
||||
t.Errorf("parseModelOptions(%q): key = %q, want %q", tt.comment, key, tt.wantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoFieldGoType(t *testing.T) {
|
||||
// Smoke test - just verify it doesn't panic with nil
|
||||
typ := protoFieldGoType(nil)
|
||||
if typ != "string" {
|
||||
// nil field returns default based on zero value TYPE_DOUBLE=0
|
||||
t.Logf("protoFieldGoType(nil) = %q", typ)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Ruff
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1,327 @@
|
||||
# LlamaIndex Go Micro Integration
|
||||
|
||||
[](https://badge.fury.io/py/go-micro-llamaindex)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
Official LlamaIndex integration for Go Micro services. This package enables LlamaIndex agents to discover and call Go Micro microservices through the Model Context Protocol (MCP).
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Service Discovery** - Discovers available services from MCP gateway
|
||||
- **Dynamic Tool Generation** - Converts service endpoints into LlamaIndex tools
|
||||
- **Rich Descriptions** - Uses service metadata for accurate tool descriptions
|
||||
- **Authentication Support** - Bearer token auth with scope-based permissions
|
||||
- **RAG Integration** - Combine service tools with LlamaIndex's RAG capabilities
|
||||
- **Type-Safe** - Fully typed with Python 3.8+ type hints
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install go-micro-llamaindex
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Your Go Micro Services
|
||||
|
||||
```bash
|
||||
# Start MCP gateway
|
||||
micro mcp serve --address :3000
|
||||
```
|
||||
|
||||
### 2. Create LlamaIndex Agent
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
from llama_index.core.agent import ReActAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
# Initialize toolkit from MCP gateway
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Create agent
|
||||
llm = OpenAI(model="gpt-4")
|
||||
agent = ReActAgent.from_tools(toolkit.get_tools(), llm=llm, verbose=True)
|
||||
|
||||
# Use the agent!
|
||||
response = agent.chat("Create a user named Alice with email alice@example.com")
|
||||
print(response)
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Tool Discovery
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
|
||||
# Connect to MCP gateway
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# List available tools
|
||||
for tool in toolkit.get_tools():
|
||||
print(f"Tool: {tool.metadata.name}")
|
||||
print(f"Description: {tool.metadata.description}")
|
||||
print()
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
|
||||
# Create toolkit with authentication
|
||||
toolkit = GoMicroToolkit.from_gateway(
|
||||
gateway_url="http://localhost:3000",
|
||||
auth_token="your-bearer-token"
|
||||
)
|
||||
|
||||
# Tools will automatically use the auth token
|
||||
tools = toolkit.get_tools()
|
||||
```
|
||||
|
||||
### Filter Tools by Service
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Get only user service tools
|
||||
user_tools = toolkit.get_tools(service_filter="users")
|
||||
|
||||
# Get tools matching a pattern
|
||||
blog_tools = toolkit.get_tools(name_pattern="blog.*")
|
||||
```
|
||||
|
||||
### Custom Tool Selection
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Select specific tools
|
||||
selected_tools = toolkit.get_tools(
|
||||
include=["users.Users.Get", "users.Users.Create"]
|
||||
)
|
||||
|
||||
# Exclude certain tools
|
||||
filtered_tools = toolkit.get_tools(
|
||||
exclude=["users.Users.Delete"]
|
||||
)
|
||||
```
|
||||
|
||||
### RAG + Microservices
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
from llama_index.core import VectorStoreIndex, Document
|
||||
from llama_index.core.agent import ReActAgent
|
||||
from llama_index.core.tools import QueryEngineTool, ToolMetadata
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Combine service tools with a RAG query engine
|
||||
index = VectorStoreIndex.from_documents([...])
|
||||
rag_tool = QueryEngineTool(
|
||||
query_engine=index.as_query_engine(),
|
||||
metadata=ToolMetadata(name="docs", description="Search documentation"),
|
||||
)
|
||||
|
||||
all_tools = [rag_tool] + toolkit.get_tools()
|
||||
agent = ReActAgent.from_tools(all_tools, llm=OpenAI(model="gpt-4"))
|
||||
```
|
||||
|
||||
### Multi-Agent Workflows
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
from llama_index.core.agent import ReActAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
llm = OpenAI(model="gpt-4")
|
||||
|
||||
# Agent 1: User management
|
||||
user_agent = ReActAgent.from_tools(
|
||||
toolkit.get_tools(service_filter="users"), llm=llm
|
||||
)
|
||||
|
||||
# Agent 2: Blog management
|
||||
blog_agent = ReActAgent.from_tools(
|
||||
toolkit.get_tools(service_filter="blog"), llm=llm
|
||||
)
|
||||
|
||||
# Coordinate between agents
|
||||
user_result = user_agent.chat("Create user Alice")
|
||||
blog_result = blog_agent.chat(f"Create blog post for {user_result}")
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit, GoMicroError
|
||||
|
||||
try:
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools()
|
||||
except GoMicroError as e:
|
||||
print(f"Error: {e}")
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```python
|
||||
from go_micro_llamaindex import GoMicroToolkit, GoMicroConfig
|
||||
|
||||
config = GoMicroConfig(
|
||||
gateway_url="http://localhost:3000",
|
||||
auth_token="your-token",
|
||||
timeout=30,
|
||||
retry_count=3,
|
||||
retry_delay=1.0,
|
||||
verify_ssl=True,
|
||||
)
|
||||
|
||||
toolkit = GoMicroToolkit(config)
|
||||
tools = toolkit.get_tools()
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### GoMicroToolkit
|
||||
|
||||
Main class for interacting with Go Micro services.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `from_gateway(gateway_url, auth_token=None, **kwargs)` - Create toolkit from MCP gateway
|
||||
- `get_tools(service_filter=None, name_pattern=None, include=None, exclude=None)` - Get LlamaIndex tools
|
||||
- `refresh()` - Refresh tool list from gateway
|
||||
- `call_tool(tool_name, arguments)` - Call a tool directly
|
||||
- `list_tools()` - Get raw list of available tools
|
||||
|
||||
### GoMicroConfig
|
||||
|
||||
Configuration for the toolkit.
|
||||
|
||||
#### Parameters
|
||||
|
||||
- `gateway_url` (str) - MCP gateway URL
|
||||
- `auth_token` (str, optional) - Bearer authentication token
|
||||
- `timeout` (int) - Request timeout in seconds (default: 30)
|
||||
- `retry_count` (int) - Number of retries (default: 3)
|
||||
- `retry_delay` (float) - Delay between retries in seconds (default: 1.0)
|
||||
- `verify_ssl` (bool) - Verify SSL certificates (default: True)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- llama-index-core >= 0.10.0
|
||||
- requests >= 2.31.0
|
||||
- pydantic >= 2.0.0
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/micro/go-micro
|
||||
cd go-micro/contrib/go-micro-llamaindex
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install in development mode
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=go_micro_llamaindex
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_toolkit.py
|
||||
```
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black go_micro_llamaindex tests
|
||||
|
||||
# Check types
|
||||
mypy go_micro_llamaindex
|
||||
|
||||
# Lint
|
||||
ruff check go_micro_llamaindex
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See the [examples](./examples) directory for complete examples:
|
||||
|
||||
- [basic_agent.py](./examples/basic_agent.py) - Simple ReAct agent
|
||||
- [rag_with_services.py](./examples/rag_with_services.py) - RAG combined with microservices
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gateway Connection Issues
|
||||
|
||||
If you can't connect to the MCP gateway:
|
||||
|
||||
1. Verify the gateway is running:
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
2. Check the gateway URL is correct
|
||||
3. Verify firewall settings
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
If you get authentication errors:
|
||||
|
||||
1. Verify your token is valid
|
||||
2. Check the token has required scopes
|
||||
3. Review gateway logs for details
|
||||
|
||||
### Tool Discovery Issues
|
||||
|
||||
If tools aren't being discovered:
|
||||
|
||||
1. List services from gateway:
|
||||
```bash
|
||||
curl http://localhost:3000/mcp/tools
|
||||
```
|
||||
|
||||
2. Verify services are registered
|
||||
3. Check service metadata is correct
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](../../CONTRIBUTING.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
|
||||
## Links
|
||||
|
||||
- [Go Micro](https://github.com/micro/go-micro)
|
||||
- [MCP Documentation](../../gateway/mcp/DOCUMENTATION.md)
|
||||
- [LlamaIndex](https://docs.llamaindex.ai/)
|
||||
- [Issue Tracker](https://github.com/micro/go-micro/issues)
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/jwTYuUVAGh
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Basic LlamaIndex agent example using Go Micro services.
|
||||
|
||||
This example shows how to create a simple LlamaIndex agent that can
|
||||
interact with Go Micro services through the MCP gateway.
|
||||
"""
|
||||
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
from llama_index.core.agent import ReActAgent
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
|
||||
def main():
|
||||
"""Run basic agent example."""
|
||||
# Initialize toolkit from MCP gateway
|
||||
print("Connecting to MCP gateway...")
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Get available tools
|
||||
tools = toolkit.get_tools()
|
||||
print(f"\nDiscovered {len(tools)} tools:")
|
||||
for tool in tools:
|
||||
print(f" - {tool.metadata.name}: {tool.metadata.description}")
|
||||
|
||||
# Create LlamaIndex ReAct agent
|
||||
print("\nCreating LlamaIndex agent...")
|
||||
llm = OpenAI(model="gpt-4", temperature=0)
|
||||
agent = ReActAgent.from_tools(tools, llm=llm, verbose=True)
|
||||
|
||||
# Example queries
|
||||
queries = [
|
||||
"Create a user named Alice with email alice@example.com",
|
||||
"Get the user we just created",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Query: {query}")
|
||||
print("=" * 60)
|
||||
response = agent.chat(query)
|
||||
print(f"\nResult: {response}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""RAG with Go Micro services example.
|
||||
|
||||
This example demonstrates how to combine LlamaIndex's RAG capabilities
|
||||
with Go Micro service tools, allowing an agent to both query documents
|
||||
and interact with microservices.
|
||||
"""
|
||||
|
||||
from go_micro_llamaindex import GoMicroToolkit
|
||||
from llama_index.core import VectorStoreIndex, Document
|
||||
from llama_index.core.agent import ReActAgent
|
||||
from llama_index.core.tools import QueryEngineTool, ToolMetadata
|
||||
from llama_index.llms.openai import OpenAI
|
||||
|
||||
|
||||
def main():
|
||||
"""Run RAG + services example."""
|
||||
# Initialize toolkit from MCP gateway
|
||||
print("Connecting to MCP gateway...")
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Get service tools (e.g., user management)
|
||||
service_tools = toolkit.get_tools(service_filter="users")
|
||||
print(f"Discovered {len(service_tools)} user service tools")
|
||||
|
||||
# Create a simple document index for RAG
|
||||
documents = [
|
||||
Document(text="Alice is the admin user with ID user-001."),
|
||||
Document(text="Bob is a regular user with ID user-002."),
|
||||
Document(text="The blog service supports creating, reading, and deleting posts."),
|
||||
Document(text="Users need the 'blog:write' scope to create blog posts."),
|
||||
]
|
||||
|
||||
print("Building document index...")
|
||||
index = VectorStoreIndex.from_documents(documents)
|
||||
query_engine = index.as_query_engine()
|
||||
|
||||
# Create a query engine tool for RAG
|
||||
rag_tool = QueryEngineTool(
|
||||
query_engine=query_engine,
|
||||
metadata=ToolMetadata(
|
||||
name="knowledge_base",
|
||||
description="Search the knowledge base for information about users, "
|
||||
"services, and permissions. Use this to look up user IDs, "
|
||||
"service capabilities, and required scopes.",
|
||||
),
|
||||
)
|
||||
|
||||
# Combine RAG tool with service tools
|
||||
all_tools = [rag_tool] + service_tools
|
||||
|
||||
# Create agent with both capabilities
|
||||
print("\nCreating agent with RAG + service tools...")
|
||||
llm = OpenAI(model="gpt-4", temperature=0)
|
||||
agent = ReActAgent.from_tools(all_tools, llm=llm, verbose=True)
|
||||
|
||||
# Example: Agent uses RAG to find user ID, then calls service
|
||||
queries = [
|
||||
"What is Alice's user ID?",
|
||||
"Look up Alice's user ID from the knowledge base, then get her full profile from the user service",
|
||||
"What scope do I need to create blog posts?",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Query: {query}")
|
||||
print("=" * 60)
|
||||
response = agent.chat(query)
|
||||
print(f"\nResult: {response}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LlamaIndex Go Micro Integration.
|
||||
|
||||
This package provides LlamaIndex integration for Go Micro services through
|
||||
the Model Context Protocol (MCP).
|
||||
"""
|
||||
|
||||
from go_micro_llamaindex.toolkit import GoMicroToolkit, GoMicroConfig
|
||||
from go_micro_llamaindex.exceptions import GoMicroError, GoMicroConnectionError, GoMicroAuthError
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"GoMicroToolkit",
|
||||
"GoMicroConfig",
|
||||
"GoMicroError",
|
||||
"GoMicroConnectionError",
|
||||
"GoMicroAuthError",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Custom exceptions for LlamaIndex Go Micro integration."""
|
||||
|
||||
|
||||
class GoMicroError(Exception):
|
||||
"""Base exception for Go Micro integration errors."""
|
||||
pass
|
||||
|
||||
|
||||
class GoMicroConnectionError(GoMicroError):
|
||||
"""Raised when unable to connect to MCP gateway."""
|
||||
pass
|
||||
|
||||
|
||||
class GoMicroAuthError(GoMicroError):
|
||||
"""Raised when authentication fails."""
|
||||
pass
|
||||
|
||||
|
||||
class GoMicroToolError(GoMicroError):
|
||||
"""Raised when tool execution fails."""
|
||||
pass
|
||||
@@ -0,0 +1,311 @@
|
||||
"""LlamaIndex toolkit for Go Micro services."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from llama_index.core.tools import FunctionTool, ToolMetadata
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from go_micro_llamaindex.exceptions import (
|
||||
GoMicroConnectionError,
|
||||
GoMicroAuthError,
|
||||
GoMicroToolError,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoMicroConfig:
|
||||
"""Configuration for Go Micro MCP gateway connection.
|
||||
|
||||
Attributes:
|
||||
gateway_url: URL of the MCP gateway (e.g., http://localhost:3000)
|
||||
auth_token: Optional bearer authentication token
|
||||
timeout: Request timeout in seconds
|
||||
retry_count: Number of retries on failure
|
||||
retry_delay: Delay between retries in seconds
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
"""
|
||||
|
||||
gateway_url: str
|
||||
auth_token: Optional[str] = None
|
||||
timeout: int = 30
|
||||
retry_count: int = 3
|
||||
retry_delay: float = 1.0
|
||||
verify_ssl: bool = True
|
||||
|
||||
|
||||
class GoMicroTool(BaseModel):
|
||||
"""Represents a Go Micro service tool.
|
||||
|
||||
Attributes:
|
||||
name: Tool name (e.g., "users.Users.Get")
|
||||
service: Service name (e.g., "users")
|
||||
endpoint: Endpoint name (e.g., "Users.Get")
|
||||
description: Tool description
|
||||
example: Example input JSON
|
||||
scopes: Required auth scopes
|
||||
metadata: Additional metadata from service
|
||||
"""
|
||||
|
||||
name: str
|
||||
service: str
|
||||
endpoint: str
|
||||
description: str
|
||||
example: Optional[str] = None
|
||||
scopes: Optional[List[str]] = None
|
||||
metadata: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GoMicroToolkit:
|
||||
"""LlamaIndex toolkit for Go Micro services.
|
||||
|
||||
This class provides integration between LlamaIndex and Go Micro services
|
||||
via the Model Context Protocol (MCP) gateway.
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> tools = toolkit.get_tools()
|
||||
>>> for tool in tools:
|
||||
... print(f"Tool: {tool.metadata.name}")
|
||||
"""
|
||||
|
||||
def __init__(self, config: GoMicroConfig):
|
||||
"""Initialize the toolkit.
|
||||
|
||||
Args:
|
||||
config: Configuration for MCP gateway connection
|
||||
"""
|
||||
self.config = config
|
||||
self._tools: Optional[List[GoMicroTool]] = None
|
||||
self._session = requests.Session()
|
||||
|
||||
if config.auth_token:
|
||||
self._session.headers.update({
|
||||
"Authorization": f"Bearer {config.auth_token}"
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def from_gateway(
|
||||
cls,
|
||||
gateway_url: str,
|
||||
auth_token: Optional[str] = None,
|
||||
**kwargs: Any
|
||||
) -> "GoMicroToolkit":
|
||||
"""Create toolkit from MCP gateway URL.
|
||||
|
||||
Args:
|
||||
gateway_url: URL of the MCP gateway
|
||||
auth_token: Optional bearer authentication token
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
GoMicroToolkit instance
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
"""
|
||||
config = GoMicroConfig(
|
||||
gateway_url=gateway_url,
|
||||
auth_token=auth_token,
|
||||
**kwargs
|
||||
)
|
||||
return cls(config)
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
**kwargs: Any
|
||||
) -> requests.Response:
|
||||
"""Make HTTP request to MCP gateway.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: API path
|
||||
**kwargs: Additional request arguments
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
|
||||
Raises:
|
||||
GoMicroConnectionError: If connection fails
|
||||
GoMicroAuthError: If authentication fails
|
||||
"""
|
||||
url = f"{self.config.gateway_url}{path}"
|
||||
kwargs.setdefault("timeout", self.config.timeout)
|
||||
kwargs.setdefault("verify", self.config.verify_ssl)
|
||||
|
||||
try:
|
||||
response = self._session.request(method, url, **kwargs)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise GoMicroAuthError("Authentication failed")
|
||||
elif response.status_code == 403:
|
||||
raise GoMicroAuthError("Forbidden: insufficient permissions")
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
except requests.ConnectionError as e:
|
||||
raise GoMicroConnectionError(
|
||||
f"Failed to connect to MCP gateway at {url}: {e}"
|
||||
)
|
||||
except requests.Timeout as e:
|
||||
raise GoMicroConnectionError(
|
||||
f"Request to MCP gateway timed out: {e}"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
if isinstance(e, (GoMicroConnectionError, GoMicroAuthError)):
|
||||
raise
|
||||
raise GoMicroConnectionError(f"Request failed: {e}")
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh tool list from MCP gateway.
|
||||
|
||||
Raises:
|
||||
GoMicroConnectionError: If unable to connect to gateway
|
||||
"""
|
||||
response = self._make_request("GET", "/mcp/tools")
|
||||
data = response.json()
|
||||
|
||||
tools_data = data.get("tools", [])
|
||||
self._tools = [
|
||||
GoMicroTool(
|
||||
name=tool["name"],
|
||||
service=tool["service"],
|
||||
endpoint=tool["endpoint"],
|
||||
description=tool.get("description", ""),
|
||||
example=tool.get("example"),
|
||||
scopes=tool.get("scopes"),
|
||||
metadata=tool.get("metadata", {})
|
||||
)
|
||||
for tool in tools_data
|
||||
]
|
||||
|
||||
def get_tools(
|
||||
self,
|
||||
service_filter: Optional[str] = None,
|
||||
name_pattern: Optional[str] = None,
|
||||
include: Optional[List[str]] = None,
|
||||
exclude: Optional[List[str]] = None,
|
||||
) -> List[FunctionTool]:
|
||||
"""Get LlamaIndex tools from Go Micro services.
|
||||
|
||||
Args:
|
||||
service_filter: Filter tools by service name
|
||||
name_pattern: Filter tools by name pattern (regex)
|
||||
include: List of tool names to include
|
||||
exclude: List of tool names to exclude
|
||||
|
||||
Returns:
|
||||
List of LlamaIndex FunctionTool objects
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> all_tools = toolkit.get_tools()
|
||||
>>> user_tools = toolkit.get_tools(service_filter="users")
|
||||
"""
|
||||
if self._tools is None:
|
||||
self.refresh()
|
||||
|
||||
tools = self._tools or []
|
||||
|
||||
if service_filter:
|
||||
tools = [t for t in tools if t.service == service_filter]
|
||||
|
||||
if name_pattern:
|
||||
pattern = re.compile(name_pattern)
|
||||
tools = [t for t in tools if pattern.match(t.name)]
|
||||
|
||||
if include:
|
||||
tools = [t for t in tools if t.name in include]
|
||||
|
||||
if exclude:
|
||||
tools = [t for t in tools if t.name not in exclude]
|
||||
|
||||
return [self._create_llamaindex_tool(tool) for tool in tools]
|
||||
|
||||
def _create_llamaindex_tool(self, tool: GoMicroTool) -> FunctionTool:
|
||||
"""Create a LlamaIndex FunctionTool from a GoMicroTool.
|
||||
|
||||
Args:
|
||||
tool: GoMicroTool to convert
|
||||
|
||||
Returns:
|
||||
LlamaIndex FunctionTool object
|
||||
"""
|
||||
toolkit = self
|
||||
|
||||
def tool_func(arguments: str) -> str:
|
||||
"""Execute the tool.
|
||||
|
||||
Args:
|
||||
arguments: JSON string with tool arguments
|
||||
|
||||
Returns:
|
||||
JSON string with tool result
|
||||
"""
|
||||
return toolkit.call_tool(tool.name, arguments)
|
||||
|
||||
description = tool.description
|
||||
if tool.example:
|
||||
description += f"\n\nExample input: {tool.example}"
|
||||
|
||||
return FunctionTool.from_defaults(
|
||||
fn=tool_func,
|
||||
name=tool.name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
def call_tool(self, tool_name: str, arguments: str) -> str:
|
||||
"""Call a specific tool directly.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to call
|
||||
arguments: JSON string with tool arguments
|
||||
|
||||
Returns:
|
||||
JSON string with tool result
|
||||
|
||||
Raises:
|
||||
GoMicroToolError: If tool execution fails
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> result = toolkit.call_tool(
|
||||
... "users.Users.Get",
|
||||
... '{"id": "user-123"}'
|
||||
... )
|
||||
"""
|
||||
try:
|
||||
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
||||
except json.JSONDecodeError as e:
|
||||
raise GoMicroToolError(f"Invalid JSON arguments: {e}")
|
||||
|
||||
try:
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
"/mcp/call",
|
||||
json={"name": tool_name, "arguments": args}
|
||||
)
|
||||
return json.dumps(response.json())
|
||||
except requests.RequestException as e:
|
||||
raise GoMicroToolError(f"Tool execution failed: {e}")
|
||||
|
||||
def list_tools(self) -> List[GoMicroTool]:
|
||||
"""Get raw list of available tools.
|
||||
|
||||
Returns:
|
||||
List of GoMicroTool objects
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> for tool in toolkit.list_tools():
|
||||
... print(f"{tool.name}: {tool.description}")
|
||||
"""
|
||||
if self._tools is None:
|
||||
self.refresh()
|
||||
return self._tools or []
|
||||
@@ -0,0 +1,72 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "go-micro-llamaindex"
|
||||
version = "0.1.0"
|
||||
description = "LlamaIndex integration for Go Micro services via MCP"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "Apache-2.0"}
|
||||
authors = [
|
||||
{name = "Micro Team", email = "hello@micro.dev"}
|
||||
]
|
||||
keywords = ["llamaindex", "go-micro", "mcp", "microservices", "ai", "rag"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"llama-index-core>=0.10.0",
|
||||
"requests>=2.31.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
"types-requests>=2.31.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/micro/go-micro"
|
||||
Documentation = "https://github.com/micro/go-micro/tree/master/contrib/go-micro-llamaindex"
|
||||
Repository = "https://github.com/micro/go-micro"
|
||||
Issues = "https://github.com/micro/go-micro/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["go_micro_llamaindex*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py39', 'py310', 'py311']
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.9"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py39"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Tests for GoMicroToolkit."""
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from go_micro_llamaindex import GoMicroToolkit, GoMicroConfig
|
||||
from go_micro_llamaindex.exceptions import (
|
||||
GoMicroConnectionError,
|
||||
GoMicroAuthError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gateway_response():
|
||||
"""Mock MCP gateway response."""
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"name": "users.Users.Get",
|
||||
"service": "users",
|
||||
"endpoint": "Users.Get",
|
||||
"description": "Get a user by ID",
|
||||
"example": '{"id": "user-123"}',
|
||||
"scopes": ["users:read"],
|
||||
"metadata": {
|
||||
"description": "Get a user by ID",
|
||||
"example": '{"id": "user-123"}',
|
||||
"scopes": "users:read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users.Users.Create",
|
||||
"service": "users",
|
||||
"endpoint": "Users.Create",
|
||||
"description": "Create a new user",
|
||||
"example": '{"name": "Alice", "email": "alice@example.com"}',
|
||||
"scopes": ["users:write"],
|
||||
"metadata": {}
|
||||
},
|
||||
{
|
||||
"name": "blog.Blog.List",
|
||||
"service": "blog",
|
||||
"endpoint": "Blog.List",
|
||||
"description": "List blog posts",
|
||||
"scopes": ["blog:read"],
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"count": 3
|
||||
}
|
||||
|
||||
|
||||
class TestGoMicroConfig:
|
||||
"""Tests for GoMicroConfig."""
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Test config default values."""
|
||||
config = GoMicroConfig(gateway_url="http://localhost:3000")
|
||||
|
||||
assert config.gateway_url == "http://localhost:3000"
|
||||
assert config.auth_token is None
|
||||
assert config.timeout == 30
|
||||
assert config.retry_count == 3
|
||||
assert config.retry_delay == 1.0
|
||||
assert config.verify_ssl is True
|
||||
|
||||
def test_config_custom_values(self):
|
||||
"""Test config with custom values."""
|
||||
config = GoMicroConfig(
|
||||
gateway_url="http://localhost:8080",
|
||||
auth_token="test-token",
|
||||
timeout=60,
|
||||
retry_count=5,
|
||||
retry_delay=2.0,
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
assert config.gateway_url == "http://localhost:8080"
|
||||
assert config.auth_token == "test-token"
|
||||
assert config.timeout == 60
|
||||
assert config.retry_count == 5
|
||||
assert config.retry_delay == 2.0
|
||||
assert config.verify_ssl is False
|
||||
|
||||
|
||||
class TestGoMicroToolkit:
|
||||
"""Tests for GoMicroToolkit."""
|
||||
|
||||
def test_from_gateway(self):
|
||||
"""Test creating toolkit from gateway URL."""
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
assert toolkit.config.gateway_url == "http://localhost:3000"
|
||||
assert toolkit.config.auth_token is None
|
||||
|
||||
def test_from_gateway_with_auth(self):
|
||||
"""Test creating toolkit with authentication."""
|
||||
toolkit = GoMicroToolkit.from_gateway(
|
||||
"http://localhost:3000",
|
||||
auth_token="test-token"
|
||||
)
|
||||
|
||||
assert toolkit.config.auth_token == "test-token"
|
||||
assert "Authorization" in toolkit._session.headers
|
||||
assert toolkit._session.headers["Authorization"] == "Bearer test-token"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_refresh(self, mock_request, mock_gateway_response):
|
||||
"""Test refreshing tool list."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
toolkit.refresh()
|
||||
|
||||
assert len(toolkit._tools) == 3
|
||||
assert toolkit._tools[0].name == "users.Users.Get"
|
||||
assert toolkit._tools[1].name == "users.Users.Create"
|
||||
assert toolkit._tools[2].name == "blog.Blog.List"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools(self, mock_request, mock_gateway_response):
|
||||
"""Test getting LlamaIndex tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools()
|
||||
|
||||
assert len(tools) == 3
|
||||
names = [t.metadata.name for t in tools]
|
||||
assert "users.Users.Get" in names
|
||||
assert "users.Users.Create" in names
|
||||
assert "blog.Blog.List" in names
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_service_filter(self, mock_request, mock_gateway_response):
|
||||
"""Test filtering tools by service."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(service_filter="users")
|
||||
|
||||
assert len(tools) == 2
|
||||
for tool in tools:
|
||||
assert "users" in tool.metadata.name
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_include(self, mock_request, mock_gateway_response):
|
||||
"""Test including specific tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(include=["users.Users.Get"])
|
||||
|
||||
assert len(tools) == 1
|
||||
assert tools[0].metadata.name == "users.Users.Get"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_exclude(self, mock_request, mock_gateway_response):
|
||||
"""Test excluding specific tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(exclude=["users.Users.Create"])
|
||||
|
||||
assert len(tools) == 2
|
||||
names = [t.metadata.name for t in tools]
|
||||
assert "users.Users.Create" not in names
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_name_pattern(self, mock_request, mock_gateway_response):
|
||||
"""Test filtering tools by name pattern."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(name_pattern="blog\\..*")
|
||||
|
||||
assert len(tools) == 1
|
||||
assert tools[0].metadata.name == "blog.Blog.List"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_call_tool(self, mock_request):
|
||||
"""Test calling a tool directly."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"user": {"id": "user-123", "name": "Alice"}}
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
result = toolkit.call_tool("users.Users.Get", '{"id": "user-123"}')
|
||||
|
||||
result_data = json.loads(result)
|
||||
assert result_data["user"]["id"] == "user-123"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_list_tools(self, mock_request, mock_gateway_response):
|
||||
"""Test listing raw tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.list_tools()
|
||||
|
||||
assert len(tools) == 3
|
||||
assert tools[0].name == "users.Users.Get"
|
||||
assert tools[0].service == "users"
|
||||
assert tools[0].scopes == ["users:read"]
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_connection_error(self, mock_request):
|
||||
"""Test handling connection errors."""
|
||||
mock_request.side_effect = requests.ConnectionError("Connection failed")
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
with pytest.raises(GoMicroConnectionError):
|
||||
toolkit.refresh()
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_auth_error(self, mock_request):
|
||||
"""Test handling authentication errors."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
with pytest.raises(GoMicroAuthError):
|
||||
toolkit.refresh()
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_timeout(self, mock_request):
|
||||
"""Test handling timeouts."""
|
||||
mock_request.side_effect = requests.Timeout("Request timed out")
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
with pytest.raises(GoMicroConnectionError):
|
||||
toolkit.refresh()
|
||||
@@ -0,0 +1,65 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Ruff
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1,105 @@
|
||||
# Contributing to LangChain Go Micro
|
||||
|
||||
Thank you for your interest in contributing to the LangChain Go Micro integration!
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/micro/go-micro
|
||||
cd go-micro/contrib/langchain-go-micro
|
||||
```
|
||||
|
||||
2. Create a virtual environment:
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. Install in development mode:
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
Run all tests:
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
Run with coverage:
|
||||
```bash
|
||||
pytest --cov=langchain_go_micro --cov-report=html
|
||||
```
|
||||
|
||||
Run specific tests:
|
||||
```bash
|
||||
pytest tests/test_toolkit.py::TestGoMicroToolkit::test_get_tools
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
We use several tools to maintain code quality:
|
||||
|
||||
### Black (code formatting)
|
||||
```bash
|
||||
black langchain_go_micro tests examples
|
||||
```
|
||||
|
||||
### MyPy (type checking)
|
||||
```bash
|
||||
mypy langchain_go_micro
|
||||
```
|
||||
|
||||
### Ruff (linting)
|
||||
```bash
|
||||
ruff check langchain_go_micro tests
|
||||
```
|
||||
|
||||
Run all checks:
|
||||
```bash
|
||||
black langchain_go_micro tests examples && \
|
||||
mypy langchain_go_micro && \
|
||||
ruff check langchain_go_micro tests
|
||||
```
|
||||
|
||||
## Testing with Real Services
|
||||
|
||||
To test with real Go Micro services:
|
||||
|
||||
1. Start example services:
|
||||
```bash
|
||||
cd ../../examples/mcp/documented
|
||||
go run main.go
|
||||
```
|
||||
|
||||
2. Run integration tests:
|
||||
```bash
|
||||
cd contrib/langchain-go-micro
|
||||
pytest tests/integration/ -v
|
||||
```
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Make your changes
|
||||
4. Run tests and code quality checks
|
||||
5. Commit your changes (`git commit -am 'Add new feature'`)
|
||||
6. Push to your fork (`git push origin feature/my-feature`)
|
||||
7. Create a Pull Request
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Include tests for new features
|
||||
- Update documentation as needed
|
||||
- Follow existing code style
|
||||
- Add entry to CHANGELOG.md
|
||||
- Ensure all tests pass
|
||||
- Keep changes focused and atomic
|
||||
|
||||
## Questions?
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/jwTYuUVAGh
|
||||
@@ -0,0 +1,373 @@
|
||||
# LangChain Go Micro Integration
|
||||
|
||||
[](https://badge.fury.io/py/langchain-go-micro)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
Official LangChain integration for Go Micro services. This package enables LangChain agents to discover and call Go Micro microservices through the Model Context Protocol (MCP).
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 **Automatic Service Discovery** - Discovers available services from MCP gateway
|
||||
- 🛠️ **Dynamic Tool Generation** - Converts service endpoints into LangChain tools
|
||||
- 📝 **Rich Descriptions** - Uses service metadata for accurate tool descriptions
|
||||
- 🔐 **Authentication Support** - Bearer token auth with scope-based permissions
|
||||
- ⚡ **Type-Safe** - Fully typed with Python 3.8+ type hints
|
||||
- 🎯 **Easy Integration** - Works with any LangChain agent
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install langchain-go-micro
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Your Go Micro Services
|
||||
|
||||
```bash
|
||||
# Start MCP gateway
|
||||
micro mcp serve --address :3000
|
||||
```
|
||||
|
||||
### 2. Create LangChain Agent
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# Initialize toolkit from MCP gateway
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Create agent
|
||||
llm = ChatOpenAI(model="gpt-4")
|
||||
agent = initialize_agent(
|
||||
toolkit.get_tools(),
|
||||
llm,
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Use the agent!
|
||||
result = agent.run("Create a user named Alice with email alice@example.com")
|
||||
print(result)
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Tool Discovery
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
|
||||
# Connect to MCP gateway
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# List available tools
|
||||
for tool in toolkit.get_tools():
|
||||
print(f"Tool: {tool.name}")
|
||||
print(f"Description: {tool.description}")
|
||||
print()
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
|
||||
# Create toolkit with authentication
|
||||
toolkit = GoMicroToolkit.from_gateway(
|
||||
gateway_url="http://localhost:3000",
|
||||
auth_token="your-bearer-token"
|
||||
)
|
||||
|
||||
# Tools will automatically use the auth token
|
||||
tools = toolkit.get_tools()
|
||||
```
|
||||
|
||||
### Filter Tools by Service
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Get only user service tools
|
||||
user_tools = toolkit.get_tools(service_filter="users")
|
||||
|
||||
# Get tools matching a pattern
|
||||
blog_tools = toolkit.get_tools(name_pattern="blog.*")
|
||||
```
|
||||
|
||||
### Custom Tool Selection
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Select specific tools
|
||||
selected_tools = toolkit.get_tools(
|
||||
include=["users.Users.Get", "users.Users.Create"]
|
||||
)
|
||||
|
||||
# Exclude certain tools
|
||||
filtered_tools = toolkit.get_tools(
|
||||
exclude=["users.Users.Delete"]
|
||||
)
|
||||
```
|
||||
|
||||
### Multi-Agent Workflows
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# Create specialized agents for different services
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Agent 1: User management
|
||||
user_agent = initialize_agent(
|
||||
toolkit.get_tools(service_filter="users"),
|
||||
ChatOpenAI(model="gpt-4"),
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION
|
||||
)
|
||||
|
||||
# Agent 2: Order processing
|
||||
order_agent = initialize_agent(
|
||||
toolkit.get_tools(service_filter="orders"),
|
||||
ChatOpenAI(model="gpt-4"),
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION
|
||||
)
|
||||
|
||||
# Coordinate between agents
|
||||
user = user_agent.run("Create user Alice")
|
||||
order = order_agent.run(f"Create order for user {user['id']}")
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit, GoMicroError
|
||||
|
||||
try:
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools()
|
||||
except GoMicroError as e:
|
||||
print(f"Error: {e}")
|
||||
# Handle error (gateway unreachable, auth failed, etc.)
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit, GoMicroConfig
|
||||
|
||||
config = GoMicroConfig(
|
||||
gateway_url="http://localhost:3000",
|
||||
auth_token="your-token",
|
||||
timeout=30, # Request timeout in seconds
|
||||
retry_count=3, # Number of retries on failure
|
||||
retry_delay=1.0, # Delay between retries
|
||||
verify_ssl=True, # SSL certificate verification
|
||||
)
|
||||
|
||||
toolkit = GoMicroToolkit(config)
|
||||
tools = toolkit.get_tools()
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### GoMicroToolkit
|
||||
|
||||
Main class for interacting with Go Micro services.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `from_gateway(gateway_url, auth_token=None, **kwargs)` - Create toolkit from MCP gateway
|
||||
- `get_tools(service_filter=None, name_pattern=None, include=None, exclude=None)` - Get LangChain tools
|
||||
- `refresh()` - Refresh tool list from gateway
|
||||
- `call_tool(tool_name, arguments)` - Call a tool directly
|
||||
|
||||
### GoMicroConfig
|
||||
|
||||
Configuration for the toolkit.
|
||||
|
||||
#### Parameters
|
||||
|
||||
- `gateway_url` (str) - MCP gateway URL
|
||||
- `auth_token` (str, optional) - Bearer authentication token
|
||||
- `timeout` (int) - Request timeout in seconds (default: 30)
|
||||
- `retry_count` (int) - Number of retries (default: 3)
|
||||
- `retry_delay` (float) - Delay between retries in seconds (default: 1.0)
|
||||
- `verify_ssl` (bool) - Verify SSL certificates (default: True)
|
||||
|
||||
## Integration with LangChain Components
|
||||
|
||||
### With LangChain Agents
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
llm = ChatOpenAI(model="gpt-4")
|
||||
|
||||
agent = initialize_agent(
|
||||
toolkit.get_tools(),
|
||||
llm,
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
### With LangChain Memory
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain.memory import ConversationBufferMemory
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
memory = ConversationBufferMemory(memory_key="chat_history")
|
||||
|
||||
agent = initialize_agent(
|
||||
toolkit.get_tools(),
|
||||
ChatOpenAI(model="gpt-4"),
|
||||
agent=AgentType.CONVERSATIONAL_REACT_DESCRIPTION,
|
||||
memory=memory,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
### With Custom LLMs
|
||||
|
||||
```python
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Use Claude instead of GPT
|
||||
agent = initialize_agent(
|
||||
toolkit.get_tools(),
|
||||
ChatAnthropic(model="claude-3-sonnet-20240229"),
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
|
||||
verbose=True
|
||||
)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- LangChain >= 0.1.0
|
||||
- requests >= 2.31.0
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/micro/go-micro
|
||||
cd go-micro/contrib/langchain-go-micro
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install in development mode
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=langchain_go_micro
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_toolkit.py
|
||||
```
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black langchain_go_micro tests
|
||||
|
||||
# Check types
|
||||
mypy langchain_go_micro
|
||||
|
||||
# Lint
|
||||
ruff check langchain_go_micro
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See the [examples](./examples) directory for complete examples:
|
||||
|
||||
- [basic_agent.py](./examples/basic_agent.py) - Simple agent example
|
||||
- [multi_agent.py](./examples/multi_agent.py) - Multi-agent workflow
|
||||
- [with_memory.py](./examples/with_memory.py) - Agent with conversation memory
|
||||
- [custom_llm.py](./examples/custom_llm.py) - Using different LLMs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gateway Connection Issues
|
||||
|
||||
If you can't connect to the MCP gateway:
|
||||
|
||||
1. Verify the gateway is running:
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
2. Check the gateway URL is correct
|
||||
3. Verify firewall settings
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
If you get authentication errors:
|
||||
|
||||
1. Verify your token is valid
|
||||
2. Check the token has required scopes
|
||||
3. Review gateway logs for details
|
||||
|
||||
### Tool Discovery Issues
|
||||
|
||||
If tools aren't being discovered:
|
||||
|
||||
1. List services from gateway:
|
||||
```bash
|
||||
curl http://localhost:3000/mcp/tools
|
||||
```
|
||||
|
||||
2. Verify services are registered
|
||||
3. Check service metadata is correct
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](../../CONTRIBUTING.md) for details.
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
|
||||
## Links
|
||||
|
||||
- [Go Micro](https://github.com/micro/go-micro)
|
||||
- [MCP Documentation](../../gateway/mcp/DOCUMENTATION.md)
|
||||
- [LangChain](https://python.langchain.com/)
|
||||
- [Issue Tracker](https://github.com/micro/go-micro/issues)
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/jwTYuUVAGh
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Basic LangChain agent example using Go Micro services.
|
||||
|
||||
This example shows how to create a simple LangChain agent that can
|
||||
interact with Go Micro services through the MCP gateway.
|
||||
"""
|
||||
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
def main():
|
||||
"""Run basic agent example."""
|
||||
# Initialize toolkit from MCP gateway
|
||||
print("Connecting to MCP gateway...")
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Get available tools
|
||||
tools = toolkit.get_tools()
|
||||
print(f"\nDiscovered {len(tools)} tools:")
|
||||
for tool in tools:
|
||||
print(f" - {tool.name}: {tool.description}")
|
||||
|
||||
# Create LangChain agent
|
||||
print("\nCreating LangChain agent...")
|
||||
llm = ChatOpenAI(model="gpt-4", temperature=0)
|
||||
agent = initialize_agent(
|
||||
tools,
|
||||
llm,
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Example queries
|
||||
queries = [
|
||||
"Create a user named Alice with email alice@example.com",
|
||||
"Get the user we just created",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Query: {query}")
|
||||
print('='*60)
|
||||
result = agent.run(query)
|
||||
print(f"\nResult: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Multi-agent workflow example.
|
||||
|
||||
This example demonstrates how to create specialized agents for different
|
||||
services and coordinate between them.
|
||||
"""
|
||||
|
||||
from langchain_go_micro import GoMicroToolkit
|
||||
from langchain.agents import initialize_agent, AgentType
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
|
||||
def main():
|
||||
"""Run multi-agent example."""
|
||||
# Connect to MCP gateway
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
# Create LLM
|
||||
llm = ChatOpenAI(model="gpt-4", temperature=0)
|
||||
|
||||
# Create specialized agents for different services
|
||||
print("Creating specialized agents...")
|
||||
|
||||
# Agent 1: User management
|
||||
user_tools = toolkit.get_tools(service_filter="users")
|
||||
user_agent = initialize_agent(
|
||||
user_tools,
|
||||
llm,
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
|
||||
verbose=True
|
||||
)
|
||||
print(f"User agent: {len(user_tools)} tools")
|
||||
|
||||
# Agent 2: Blog management
|
||||
blog_tools = toolkit.get_tools(service_filter="blog")
|
||||
blog_agent = initialize_agent(
|
||||
blog_tools,
|
||||
llm,
|
||||
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
|
||||
verbose=True
|
||||
)
|
||||
print(f"Blog agent: {len(blog_tools)} tools")
|
||||
|
||||
# Coordinate between agents
|
||||
print("\n" + "="*60)
|
||||
print("Multi-agent workflow")
|
||||
print("="*60)
|
||||
|
||||
# Step 1: Create a user
|
||||
print("\nStep 1: Creating user...")
|
||||
user_result = user_agent.run(
|
||||
"Create a user named Bob Smith with email bob@example.com"
|
||||
)
|
||||
print(f"User created: {user_result}")
|
||||
|
||||
# Step 2: Create a blog post for that user
|
||||
print("\nStep 2: Creating blog post...")
|
||||
blog_result = blog_agent.run(
|
||||
f"Create a blog post titled 'Hello World' with content "
|
||||
f"'This is my first post' by user {user_result}"
|
||||
)
|
||||
print(f"Blog post created: {blog_result}")
|
||||
|
||||
# Step 3: List user's posts
|
||||
print("\nStep 3: Listing user's posts...")
|
||||
posts = blog_agent.run(f"List all blog posts by {user_result}")
|
||||
print(f"User's posts: {posts}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LangChain Go Micro Integration.
|
||||
|
||||
This package provides LangChain integration for Go Micro services through
|
||||
the Model Context Protocol (MCP).
|
||||
"""
|
||||
|
||||
from langchain_go_micro.toolkit import GoMicroToolkit, GoMicroConfig
|
||||
from langchain_go_micro.exceptions import GoMicroError, GoMicroConnectionError, GoMicroAuthError
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"GoMicroToolkit",
|
||||
"GoMicroConfig",
|
||||
"GoMicroError",
|
||||
"GoMicroConnectionError",
|
||||
"GoMicroAuthError",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Custom exceptions for LangChain Go Micro integration."""
|
||||
|
||||
|
||||
class GoMicroError(Exception):
|
||||
"""Base exception for Go Micro integration errors."""
|
||||
pass
|
||||
|
||||
|
||||
class GoMicroConnectionError(GoMicroError):
|
||||
"""Raised when unable to connect to MCP gateway."""
|
||||
pass
|
||||
|
||||
|
||||
class GoMicroAuthError(GoMicroError):
|
||||
"""Raised when authentication fails."""
|
||||
pass
|
||||
|
||||
|
||||
class GoMicroToolError(GoMicroError):
|
||||
"""Raised when tool execution fails."""
|
||||
pass
|
||||
@@ -0,0 +1,319 @@
|
||||
"""LangChain toolkit for Go Micro services."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from langchain.tools import Tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from langchain_go_micro.exceptions import (
|
||||
GoMicroConnectionError,
|
||||
GoMicroAuthError,
|
||||
GoMicroToolError,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoMicroConfig:
|
||||
"""Configuration for Go Micro MCP gateway connection.
|
||||
|
||||
Attributes:
|
||||
gateway_url: URL of the MCP gateway (e.g., http://localhost:3000)
|
||||
auth_token: Optional bearer authentication token
|
||||
timeout: Request timeout in seconds
|
||||
retry_count: Number of retries on failure
|
||||
retry_delay: Delay between retries in seconds
|
||||
verify_ssl: Whether to verify SSL certificates
|
||||
"""
|
||||
|
||||
gateway_url: str
|
||||
auth_token: Optional[str] = None
|
||||
timeout: int = 30
|
||||
retry_count: int = 3
|
||||
retry_delay: float = 1.0
|
||||
verify_ssl: bool = True
|
||||
|
||||
|
||||
class GoMicroTool(BaseModel):
|
||||
"""Represents a Go Micro service tool.
|
||||
|
||||
Attributes:
|
||||
name: Tool name (e.g., "users.Users.Get")
|
||||
service: Service name (e.g., "users")
|
||||
endpoint: Endpoint name (e.g., "Users.Get")
|
||||
description: Tool description
|
||||
example: Example input JSON
|
||||
scopes: Required auth scopes
|
||||
metadata: Additional metadata from service
|
||||
"""
|
||||
|
||||
name: str
|
||||
service: str
|
||||
endpoint: str
|
||||
description: str
|
||||
example: Optional[str] = None
|
||||
scopes: Optional[List[str]] = None
|
||||
metadata: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GoMicroToolkit:
|
||||
"""LangChain toolkit for Go Micro services.
|
||||
|
||||
This class provides integration between LangChain and Go Micro services
|
||||
via the Model Context Protocol (MCP) gateway.
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> tools = toolkit.get_tools()
|
||||
>>> for tool in tools:
|
||||
... print(f"Tool: {tool.name}")
|
||||
"""
|
||||
|
||||
def __init__(self, config: GoMicroConfig):
|
||||
"""Initialize the toolkit.
|
||||
|
||||
Args:
|
||||
config: Configuration for MCP gateway connection
|
||||
"""
|
||||
self.config = config
|
||||
self._tools: Optional[List[GoMicroTool]] = None
|
||||
self._session = requests.Session()
|
||||
|
||||
# Set up authentication
|
||||
if config.auth_token:
|
||||
self._session.headers.update({
|
||||
"Authorization": f"Bearer {config.auth_token}"
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def from_gateway(
|
||||
cls,
|
||||
gateway_url: str,
|
||||
auth_token: Optional[str] = None,
|
||||
**kwargs: Any
|
||||
) -> "GoMicroToolkit":
|
||||
"""Create toolkit from MCP gateway URL.
|
||||
|
||||
Args:
|
||||
gateway_url: URL of the MCP gateway
|
||||
auth_token: Optional bearer authentication token
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
GoMicroToolkit instance
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
"""
|
||||
config = GoMicroConfig(
|
||||
gateway_url=gateway_url,
|
||||
auth_token=auth_token,
|
||||
**kwargs
|
||||
)
|
||||
return cls(config)
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
**kwargs: Any
|
||||
) -> requests.Response:
|
||||
"""Make HTTP request to MCP gateway.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: API path
|
||||
**kwargs: Additional request arguments
|
||||
|
||||
Returns:
|
||||
Response object
|
||||
|
||||
Raises:
|
||||
GoMicroConnectionError: If connection fails
|
||||
GoMicroAuthError: If authentication fails
|
||||
"""
|
||||
url = f"{self.config.gateway_url}{path}"
|
||||
kwargs.setdefault("timeout", self.config.timeout)
|
||||
kwargs.setdefault("verify", self.config.verify_ssl)
|
||||
|
||||
try:
|
||||
response = self._session.request(method, url, **kwargs)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise GoMicroAuthError("Authentication failed")
|
||||
elif response.status_code == 403:
|
||||
raise GoMicroAuthError("Forbidden: insufficient permissions")
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
except requests.ConnectionError as e:
|
||||
raise GoMicroConnectionError(
|
||||
f"Failed to connect to MCP gateway at {url}: {e}"
|
||||
)
|
||||
except requests.Timeout as e:
|
||||
raise GoMicroConnectionError(
|
||||
f"Request to MCP gateway timed out: {e}"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
if isinstance(e, (GoMicroConnectionError, GoMicroAuthError)):
|
||||
raise
|
||||
raise GoMicroConnectionError(f"Request failed: {e}")
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh tool list from MCP gateway.
|
||||
|
||||
Raises:
|
||||
GoMicroConnectionError: If unable to connect to gateway
|
||||
"""
|
||||
response = self._make_request("GET", "/mcp/tools")
|
||||
data = response.json()
|
||||
|
||||
tools_data = data.get("tools", [])
|
||||
self._tools = [
|
||||
GoMicroTool(
|
||||
name=tool["name"],
|
||||
service=tool["service"],
|
||||
endpoint=tool["endpoint"],
|
||||
description=tool.get("description", ""),
|
||||
example=tool.get("example"),
|
||||
scopes=tool.get("scopes"),
|
||||
metadata=tool.get("metadata", {})
|
||||
)
|
||||
for tool in tools_data
|
||||
]
|
||||
|
||||
def get_tools(
|
||||
self,
|
||||
service_filter: Optional[str] = None,
|
||||
name_pattern: Optional[str] = None,
|
||||
include: Optional[List[str]] = None,
|
||||
exclude: Optional[List[str]] = None,
|
||||
) -> List[Tool]:
|
||||
"""Get LangChain tools from Go Micro services.
|
||||
|
||||
Args:
|
||||
service_filter: Filter tools by service name
|
||||
name_pattern: Filter tools by name pattern (regex)
|
||||
include: List of tool names to include
|
||||
exclude: List of tool names to exclude
|
||||
|
||||
Returns:
|
||||
List of LangChain Tool objects
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> # Get all tools
|
||||
>>> all_tools = toolkit.get_tools()
|
||||
>>> # Get only user service tools
|
||||
>>> user_tools = toolkit.get_tools(service_filter="users")
|
||||
>>> # Get specific tools
|
||||
>>> selected_tools = toolkit.get_tools(include=["users.Users.Get"])
|
||||
"""
|
||||
if self._tools is None:
|
||||
self.refresh()
|
||||
|
||||
tools = self._tools or []
|
||||
|
||||
# Apply filters
|
||||
if service_filter:
|
||||
tools = [t for t in tools if t.service == service_filter]
|
||||
|
||||
if name_pattern:
|
||||
pattern = re.compile(name_pattern)
|
||||
tools = [t for t in tools if pattern.match(t.name)]
|
||||
|
||||
if include:
|
||||
tools = [t for t in tools if t.name in include]
|
||||
|
||||
if exclude:
|
||||
tools = [t for t in tools if t.name not in exclude]
|
||||
|
||||
# Convert to LangChain tools
|
||||
return [self._create_langchain_tool(tool) for tool in tools]
|
||||
|
||||
def _create_langchain_tool(self, tool: GoMicroTool) -> Tool:
|
||||
"""Create a LangChain Tool from a GoMicroTool.
|
||||
|
||||
Args:
|
||||
tool: GoMicroTool to convert
|
||||
|
||||
Returns:
|
||||
LangChain Tool object
|
||||
"""
|
||||
def tool_func(arguments: str) -> str:
|
||||
"""Execute the tool.
|
||||
|
||||
Args:
|
||||
arguments: JSON string with tool arguments
|
||||
|
||||
Returns:
|
||||
JSON string with tool result
|
||||
"""
|
||||
return self.call_tool(tool.name, arguments)
|
||||
|
||||
# Build description with example if available
|
||||
description = tool.description
|
||||
if tool.example:
|
||||
description += f"\n\nExample input: {tool.example}"
|
||||
|
||||
return Tool(
|
||||
name=tool.name,
|
||||
func=tool_func,
|
||||
description=description,
|
||||
)
|
||||
|
||||
def call_tool(self, tool_name: str, arguments: str) -> str:
|
||||
"""Call a specific tool directly.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to call
|
||||
arguments: JSON string with tool arguments
|
||||
|
||||
Returns:
|
||||
JSON string with tool result
|
||||
|
||||
Raises:
|
||||
GoMicroToolError: If tool execution fails
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> result = toolkit.call_tool(
|
||||
... "users.Users.Get",
|
||||
... '{"id": "user-123"}'
|
||||
... )
|
||||
"""
|
||||
# Parse arguments
|
||||
try:
|
||||
args = json.loads(arguments) if isinstance(arguments, str) else arguments
|
||||
except json.JSONDecodeError as e:
|
||||
raise GoMicroToolError(f"Invalid JSON arguments: {e}")
|
||||
|
||||
# Make request
|
||||
try:
|
||||
response = self._make_request(
|
||||
"POST",
|
||||
"/mcp/call",
|
||||
json={"name": tool_name, "arguments": args}
|
||||
)
|
||||
return json.dumps(response.json())
|
||||
except requests.RequestException as e:
|
||||
raise GoMicroToolError(f"Tool execution failed: {e}")
|
||||
|
||||
def list_tools(self) -> List[GoMicroTool]:
|
||||
"""Get raw list of available tools.
|
||||
|
||||
Returns:
|
||||
List of GoMicroTool objects
|
||||
|
||||
Example:
|
||||
>>> toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
>>> for tool in toolkit.list_tools():
|
||||
... print(f"{tool.name}: {tool.description}")
|
||||
"""
|
||||
if self._tools is None:
|
||||
self.refresh()
|
||||
return self._tools or []
|
||||
@@ -0,0 +1,73 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "langchain-go-micro"
|
||||
version = "0.1.0"
|
||||
description = "LangChain integration for Go Micro services via MCP"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "Apache-2.0"}
|
||||
authors = [
|
||||
{name = "Micro Team", email = "hello@micro.dev"}
|
||||
]
|
||||
keywords = ["langchain", "go-micro", "mcp", "microservices", "ai", "agents"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"langchain>=0.1.0",
|
||||
"requests>=2.31.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
"types-requests>=2.31.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/micro/go-micro"
|
||||
Documentation = "https://github.com/micro/go-micro/tree/master/contrib/langchain-go-micro"
|
||||
Repository = "https://github.com/micro/go-micro"
|
||||
Issues = "https://github.com/micro/go-micro/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["langchain_go_micro*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py38', 'py39', 'py310', 'py311']
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.8"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Tests for GoMicroToolkit."""
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from langchain_go_micro import GoMicroToolkit, GoMicroConfig
|
||||
from langchain_go_micro.exceptions import (
|
||||
GoMicroConnectionError,
|
||||
GoMicroAuthError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gateway_response():
|
||||
"""Mock MCP gateway response."""
|
||||
return {
|
||||
"tools": [
|
||||
{
|
||||
"name": "users.Users.Get",
|
||||
"service": "users",
|
||||
"endpoint": "Users.Get",
|
||||
"description": "Get a user by ID",
|
||||
"example": '{"id": "user-123"}',
|
||||
"scopes": ["users:read"],
|
||||
"metadata": {
|
||||
"description": "Get a user by ID",
|
||||
"example": '{"id": "user-123"}',
|
||||
"scopes": "users:read"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "users.Users.Create",
|
||||
"service": "users",
|
||||
"endpoint": "Users.Create",
|
||||
"description": "Create a new user",
|
||||
"example": '{"name": "Alice", "email": "alice@example.com"}',
|
||||
"scopes": ["users:write"],
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"count": 2
|
||||
}
|
||||
|
||||
|
||||
class TestGoMicroConfig:
|
||||
"""Tests for GoMicroConfig."""
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Test config default values."""
|
||||
config = GoMicroConfig(gateway_url="http://localhost:3000")
|
||||
|
||||
assert config.gateway_url == "http://localhost:3000"
|
||||
assert config.auth_token is None
|
||||
assert config.timeout == 30
|
||||
assert config.retry_count == 3
|
||||
assert config.retry_delay == 1.0
|
||||
assert config.verify_ssl is True
|
||||
|
||||
def test_config_custom_values(self):
|
||||
"""Test config with custom values."""
|
||||
config = GoMicroConfig(
|
||||
gateway_url="http://localhost:8080",
|
||||
auth_token="test-token",
|
||||
timeout=60,
|
||||
retry_count=5,
|
||||
retry_delay=2.0,
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
assert config.gateway_url == "http://localhost:8080"
|
||||
assert config.auth_token == "test-token"
|
||||
assert config.timeout == 60
|
||||
assert config.retry_count == 5
|
||||
assert config.retry_delay == 2.0
|
||||
assert config.verify_ssl is False
|
||||
|
||||
|
||||
class TestGoMicroToolkit:
|
||||
"""Tests for GoMicroToolkit."""
|
||||
|
||||
def test_from_gateway(self):
|
||||
"""Test creating toolkit from gateway URL."""
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
assert toolkit.config.gateway_url == "http://localhost:3000"
|
||||
assert toolkit.config.auth_token is None
|
||||
|
||||
def test_from_gateway_with_auth(self):
|
||||
"""Test creating toolkit with authentication."""
|
||||
toolkit = GoMicroToolkit.from_gateway(
|
||||
"http://localhost:3000",
|
||||
auth_token="test-token"
|
||||
)
|
||||
|
||||
assert toolkit.config.auth_token == "test-token"
|
||||
assert "Authorization" in toolkit._session.headers
|
||||
assert toolkit._session.headers["Authorization"] == "Bearer test-token"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_refresh(self, mock_request, mock_gateway_response):
|
||||
"""Test refreshing tool list."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
toolkit.refresh()
|
||||
|
||||
assert len(toolkit._tools) == 2
|
||||
assert toolkit._tools[0].name == "users.Users.Get"
|
||||
assert toolkit._tools[1].name == "users.Users.Create"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools(self, mock_request, mock_gateway_response):
|
||||
"""Test getting LangChain tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert tools[0].name == "users.Users.Get"
|
||||
assert tools[1].name == "users.Users.Create"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_service_filter(self, mock_request, mock_gateway_response):
|
||||
"""Test filtering tools by service."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(service_filter="users")
|
||||
|
||||
assert len(tools) == 2
|
||||
for tool in tools:
|
||||
assert "users" in tool.name
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_include(self, mock_request, mock_gateway_response):
|
||||
"""Test including specific tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(include=["users.Users.Get"])
|
||||
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "users.Users.Get"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_get_tools_with_exclude(self, mock_request, mock_gateway_response):
|
||||
"""Test excluding specific tools."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = mock_gateway_response
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
tools = toolkit.get_tools(exclude=["users.Users.Create"])
|
||||
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "users.Users.Get"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_call_tool(self, mock_request):
|
||||
"""Test calling a tool directly."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"user": {"id": "user-123", "name": "Alice"}}
|
||||
mock_response.status_code = 200
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
result = toolkit.call_tool("users.Users.Get", '{"id": "user-123"}')
|
||||
|
||||
result_data = json.loads(result)
|
||||
assert result_data["user"]["id"] == "user-123"
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_connection_error(self, mock_request):
|
||||
"""Test handling connection errors."""
|
||||
mock_request.side_effect = requests.ConnectionError("Connection failed")
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
with pytest.raises(GoMicroConnectionError):
|
||||
toolkit.refresh()
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_auth_error(self, mock_request):
|
||||
"""Test handling authentication errors."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
mock_request.return_value = mock_response
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
with pytest.raises(GoMicroAuthError):
|
||||
toolkit.refresh()
|
||||
|
||||
@patch("requests.Session.request")
|
||||
def test_timeout(self, mock_request):
|
||||
"""Test handling timeouts."""
|
||||
mock_request.side_effect = requests.Timeout("Request timed out")
|
||||
|
||||
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
|
||||
|
||||
with pytest.raises(GoMicroConnectionError):
|
||||
toolkit.refresh()
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"go-micro.dev/v5/debug/log"
|
||||
"go-micro.dev/v5/util/ring"
|
||||
"go-micro.dev/v5/internal/util/ring"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-micro.dev/v5/util/ring"
|
||||
"go-micro.dev/v5/internal/util/ring"
|
||||
)
|
||||
|
||||
// Should stream from OS.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5/util/ring"
|
||||
"go-micro.dev/v5/internal/util/ring"
|
||||
)
|
||||
|
||||
type stats struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go-micro.dev/v5/util/ring"
|
||||
"go-micro.dev/v5/internal/util/ring"
|
||||
)
|
||||
|
||||
type memTracer struct {
|
||||
|
||||
+30
-3
@@ -34,13 +34,40 @@ cd web-service
|
||||
go run .
|
||||
```
|
||||
|
||||
## Coming Soon
|
||||
### [multi-service](./multi-service/)
|
||||
Multiple services in a single binary — the modular monolith pattern:
|
||||
- Isolated server, client, store, and cache per service
|
||||
- Shared registry and broker for inter-service communication
|
||||
- Coordinated lifecycle with `service.Group`
|
||||
- Start monolith, split later when you need to scale independently
|
||||
|
||||
The following examples are planned:
|
||||
**Run it:**
|
||||
```bash
|
||||
cd multi-service
|
||||
go run .
|
||||
```
|
||||
|
||||
### [deployment](./deployment/)
|
||||
Docker Compose deployment with MCP gateway, Consul registry, and Jaeger tracing:
|
||||
- Production-like architecture in one `docker-compose up`
|
||||
- Standalone MCP gateway connected to service registry
|
||||
- Distributed tracing with OpenTelemetry + Jaeger
|
||||
|
||||
### MCP Examples
|
||||
|
||||
See the [mcp/](./mcp/) directory for AI agent integration examples:
|
||||
- **[hello](./mcp/hello/)** - Minimal MCP service (start here)
|
||||
- **[crud](./mcp/crud/)** - CRUD contact book with full agent documentation
|
||||
- **[workflow](./mcp/workflow/)** - Cross-service orchestration via AI agents
|
||||
- **[documented](./mcp/documented/)** - All MCP features with auth scopes
|
||||
|
||||
### [agent-demo](./agent-demo/)
|
||||
Multi-service project management app (Projects, Tasks, Team) with seed data and agent playground integration.
|
||||
|
||||
## Coming Soon
|
||||
|
||||
- **pubsub-events** - Event-driven architecture with NATS
|
||||
- **grpc-integration** - Using go-micro with gRPC
|
||||
- **production-ready** - Complete production-grade service with observability
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent Demo
|
||||
|
||||
A multi-service project management app that demonstrates AI agents interacting with Go Micro services through MCP.
|
||||
|
||||
## What's Included
|
||||
|
||||
Three services registered in a single process:
|
||||
|
||||
| Service | Endpoints | Description |
|
||||
|---------|-----------|-------------|
|
||||
| **ProjectService** | Create, Get, List | Manage projects with status tracking |
|
||||
| **TaskService** | Create, List, Update | Tasks with assignees, priorities, and status |
|
||||
| **TeamService** | Add, List, Get | Team members with roles and skills |
|
||||
|
||||
The demo starts with seed data: 2 projects, 7 tasks, and 4 team members.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
- **MCP Gateway:** http://localhost:3000
|
||||
- **MCP Tools:** http://localhost:3000/mcp/tools
|
||||
- **WebSocket:** ws://localhost:3000/mcp/ws
|
||||
|
||||
## Use with Claude Code
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"demo": {
|
||||
"command": "go",
|
||||
"args": ["run", "main.go"],
|
||||
"cwd": "examples/agent-demo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Prompts
|
||||
|
||||
Try these with Claude Code or any MCP client:
|
||||
|
||||
- "What projects do we have?"
|
||||
- "Show me all tasks assigned to alice"
|
||||
- "Create a high-priority task for bob to review the design mockups"
|
||||
- "Who on the team knows Go?"
|
||||
- "Give me a status update on the Website Redesign project"
|
||||
- "What tasks are still todo on the API v2 migration?"
|
||||
- "Assign the unassigned tasks to charlie"
|
||||
- "Mark task-1 as done"
|
||||
|
||||
## What This Demonstrates
|
||||
|
||||
1. **Zero-config MCP** — Services become AI tools automatically from doc comments
|
||||
2. **Cross-service orchestration** — An agent queries projects, tasks, and team in one conversation
|
||||
3. **Rich tool descriptions** — `description` struct tags and `@example` comments guide the agent
|
||||
4. **Auth scopes** — Read and write operations have separate scopes
|
||||
5. **`WithMCP` one-liner** — MCP gateway starts with a single option
|
||||
|
||||
See the [blog post](/blog/4) for a detailed walkthrough.
|
||||
@@ -0,0 +1,454 @@
|
||||
// Agent Demo — A multi-service project management app
|
||||
//
|
||||
// This example shows three Go Micro services (projects, tasks, team)
|
||||
// working together through the MCP gateway, letting an AI agent
|
||||
// manage projects using natural language.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run main.go
|
||||
//
|
||||
// Then open the agent playground at http://localhost:8080/agent
|
||||
// or connect Claude Code via: micro mcp serve
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
"go-micro.dev/v5/server"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id" description:"Unique project identifier"`
|
||||
Name string `json:"name" description:"Project name"`
|
||||
Description string `json:"description" description:"What the project is about"`
|
||||
Status string `json:"status" description:"Project status: planning, active, or completed"`
|
||||
CreatedAt time.Time `json:"created_at" description:"When the project was created"`
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name" description:"Project name (required)"`
|
||||
Description string `json:"description" description:"Short description of the project"`
|
||||
}
|
||||
|
||||
type CreateProjectResponse struct {
|
||||
Project *Project `json:"project" description:"The newly created project"`
|
||||
}
|
||||
|
||||
type GetProjectRequest struct {
|
||||
ID string `json:"id" description:"Project ID to retrieve"`
|
||||
}
|
||||
|
||||
type GetProjectResponse struct {
|
||||
Project *Project `json:"project" description:"The requested project"`
|
||||
}
|
||||
|
||||
type ListProjectsRequest struct {
|
||||
Status string `json:"status,omitempty" description:"Filter by status: planning, active, completed (optional)"`
|
||||
}
|
||||
|
||||
type ListProjectsResponse struct {
|
||||
Projects []*Project `json:"projects" description:"List of matching projects"`
|
||||
}
|
||||
|
||||
type ProjectService struct {
|
||||
mu sync.RWMutex
|
||||
projects map[string]*Project
|
||||
nextID int
|
||||
}
|
||||
|
||||
// Create creates a new project with the given name and description.
|
||||
// Returns the project with a generated ID and initial status of "planning".
|
||||
//
|
||||
// @example {"name": "Website Redesign", "description": "Redesign the company website with new branding"}
|
||||
func (s *ProjectService) Create(ctx context.Context, req *CreateProjectRequest, rsp *CreateProjectResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
p := &Project{
|
||||
ID: fmt.Sprintf("proj-%d", s.nextID),
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Status: "planning",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.projects[p.ID] = p
|
||||
rsp.Project = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a project by ID.
|
||||
// Returns an error if the project does not exist.
|
||||
//
|
||||
// @example {"id": "proj-1"}
|
||||
func (s *ProjectService) Get(ctx context.Context, req *GetProjectRequest, rsp *GetProjectResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, ok := s.projects[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("project %s not found", req.ID)
|
||||
}
|
||||
rsp.Project = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all projects, optionally filtered by status.
|
||||
// Valid status values: planning, active, completed.
|
||||
//
|
||||
// @example {"status": "active"}
|
||||
func (s *ProjectService) List(ctx context.Context, req *ListProjectsRequest, rsp *ListProjectsResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, p := range s.projects {
|
||||
if req.Status == "" || p.Status == req.Status {
|
||||
rsp.Projects = append(rsp.Projects, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Task struct {
|
||||
ID string `json:"id" description:"Unique task identifier"`
|
||||
ProjectID string `json:"project_id" description:"ID of the project this task belongs to"`
|
||||
Title string `json:"title" description:"Short task title"`
|
||||
Status string `json:"status" description:"Task status: todo, in_progress, or done"`
|
||||
Assignee string `json:"assignee,omitempty" description:"Username of the person assigned"`
|
||||
Priority string `json:"priority" description:"Priority: low, medium, or high"`
|
||||
}
|
||||
|
||||
type CreateTaskRequest struct {
|
||||
ProjectID string `json:"project_id" description:"Project ID to add the task to (required)"`
|
||||
Title string `json:"title" description:"Task title (required)"`
|
||||
Assignee string `json:"assignee,omitempty" description:"Username to assign (optional)"`
|
||||
Priority string `json:"priority,omitempty" description:"Priority: low, medium, or high (default: medium)"`
|
||||
}
|
||||
|
||||
type CreateTaskResponse struct {
|
||||
Task *Task `json:"task" description:"The newly created task"`
|
||||
}
|
||||
|
||||
type ListTasksRequest struct {
|
||||
ProjectID string `json:"project_id,omitempty" description:"Filter by project ID (optional)"`
|
||||
Assignee string `json:"assignee,omitempty" description:"Filter by assignee username (optional)"`
|
||||
Status string `json:"status,omitempty" description:"Filter by status: todo, in_progress, done (optional)"`
|
||||
}
|
||||
|
||||
type ListTasksResponse struct {
|
||||
Tasks []*Task `json:"tasks" description:"List of matching tasks"`
|
||||
}
|
||||
|
||||
type UpdateTaskRequest struct {
|
||||
ID string `json:"id" description:"Task ID to update"`
|
||||
Status string `json:"status,omitempty" description:"New status: todo, in_progress, or done"`
|
||||
Assignee string `json:"assignee,omitempty" description:"New assignee username"`
|
||||
}
|
||||
|
||||
type UpdateTaskResponse struct {
|
||||
Task *Task `json:"task" description:"The updated task"`
|
||||
}
|
||||
|
||||
type TaskService struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*Task
|
||||
nextID int
|
||||
}
|
||||
|
||||
// Create creates a new task in a project.
|
||||
// Returns the task with a generated ID, initial status of "todo", and default priority of "medium".
|
||||
//
|
||||
// @example {"project_id": "proj-1", "title": "Design homepage mockup", "assignee": "alice", "priority": "high"}
|
||||
func (s *TaskService) Create(ctx context.Context, req *CreateTaskRequest, rsp *CreateTaskResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
priority := req.Priority
|
||||
if priority == "" {
|
||||
priority = "medium"
|
||||
}
|
||||
t := &Task{
|
||||
ID: fmt.Sprintf("task-%d", s.nextID),
|
||||
ProjectID: req.ProjectID,
|
||||
Title: req.Title,
|
||||
Status: "todo",
|
||||
Assignee: req.Assignee,
|
||||
Priority: priority,
|
||||
}
|
||||
s.tasks[t.ID] = t
|
||||
rsp.Task = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns tasks filtered by project, assignee, or status.
|
||||
// All filters are optional; omit all to list every task.
|
||||
//
|
||||
// @example {"project_id": "proj-1", "status": "todo"}
|
||||
func (s *TaskService) List(ctx context.Context, req *ListTasksRequest, rsp *ListTasksResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, t := range s.tasks {
|
||||
if req.ProjectID != "" && t.ProjectID != req.ProjectID {
|
||||
continue
|
||||
}
|
||||
if req.Assignee != "" && t.Assignee != req.Assignee {
|
||||
continue
|
||||
}
|
||||
if req.Status != "" && t.Status != req.Status {
|
||||
continue
|
||||
}
|
||||
rsp.Tasks = append(rsp.Tasks, t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies a task's status or assignee.
|
||||
// Only provided fields are changed; omitted fields stay the same.
|
||||
// Returns an error if the task does not exist.
|
||||
//
|
||||
// @example {"id": "task-1", "status": "in_progress"}
|
||||
func (s *TaskService) Update(ctx context.Context, req *UpdateTaskRequest, rsp *UpdateTaskResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.tasks[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("task %s not found", req.ID)
|
||||
}
|
||||
if req.Status != "" {
|
||||
t.Status = req.Status
|
||||
}
|
||||
if req.Assignee != "" {
|
||||
t.Assignee = req.Assignee
|
||||
}
|
||||
rsp.Task = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Member struct {
|
||||
Username string `json:"username" description:"Unique username"`
|
||||
Name string `json:"name" description:"Display name"`
|
||||
Role string `json:"role" description:"Role: engineer, designer, or manager"`
|
||||
Skills []string `json:"skills" description:"List of skills (e.g. go, react, figma)"`
|
||||
}
|
||||
|
||||
type AddMemberRequest struct {
|
||||
Username string `json:"username" description:"Unique username (required)"`
|
||||
Name string `json:"name" description:"Display name (required)"`
|
||||
Role string `json:"role" description:"Role: engineer, designer, or manager"`
|
||||
Skills []string `json:"skills,omitempty" description:"List of skills"`
|
||||
}
|
||||
|
||||
type AddMemberResponse struct {
|
||||
Member *Member `json:"member" description:"The added team member"`
|
||||
}
|
||||
|
||||
type ListMembersRequest struct {
|
||||
Role string `json:"role,omitempty" description:"Filter by role: engineer, designer, manager (optional)"`
|
||||
Skill string `json:"skill,omitempty" description:"Filter by skill (optional, e.g. 'go' or 'react')"`
|
||||
}
|
||||
|
||||
type ListMembersResponse struct {
|
||||
Members []*Member `json:"members" description:"List of matching team members"`
|
||||
}
|
||||
|
||||
type GetMemberRequest struct {
|
||||
Username string `json:"username" description:"Username to look up"`
|
||||
}
|
||||
|
||||
type GetMemberResponse struct {
|
||||
Member *Member `json:"member" description:"The team member"`
|
||||
}
|
||||
|
||||
type TeamService struct {
|
||||
mu sync.RWMutex
|
||||
members map[string]*Member
|
||||
}
|
||||
|
||||
// Add adds a new team member.
|
||||
// Returns the member with their assigned role and skills.
|
||||
//
|
||||
// @example {"username": "alice", "name": "Alice Chen", "role": "engineer", "skills": ["go", "react"]}
|
||||
func (s *TeamService) Add(ctx context.Context, req *AddMemberRequest, rsp *AddMemberResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := &Member{
|
||||
Username: req.Username,
|
||||
Name: req.Name,
|
||||
Role: req.Role,
|
||||
Skills: req.Skills,
|
||||
}
|
||||
s.members[m.Username] = m
|
||||
rsp.Member = m
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns team members, optionally filtered by role or skill.
|
||||
//
|
||||
// @example {"role": "engineer"}
|
||||
func (s *TeamService) List(ctx context.Context, req *ListMembersRequest, rsp *ListMembersResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, m := range s.members {
|
||||
if req.Role != "" && m.Role != req.Role {
|
||||
continue
|
||||
}
|
||||
if req.Skill != "" && !hasSkill(m.Skills, req.Skill) {
|
||||
continue
|
||||
}
|
||||
rsp.Members = append(rsp.Members, m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a team member by username.
|
||||
// Returns an error if the member does not exist.
|
||||
//
|
||||
// @example {"username": "alice"}
|
||||
func (s *TeamService) Get(ctx context.Context, req *GetMemberRequest, rsp *GetMemberResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
m, ok := s.members[req.Username]
|
||||
if !ok {
|
||||
return fmt.Errorf("member %s not found", req.Username)
|
||||
}
|
||||
rsp.Member = m
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasSkill(skills []string, target string) bool {
|
||||
for _, s := range skills {
|
||||
if strings.EqualFold(s, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main — wire everything together
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
// Create the service
|
||||
service := micro.New("demo",
|
||||
micro.Address(":9090"),
|
||||
// Start MCP gateway alongside the service
|
||||
mcp.WithMCP(":3000"),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
// Register all three handlers with scopes
|
||||
service.Handle(
|
||||
&ProjectService{projects: make(map[string]*Project)},
|
||||
server.WithEndpointScopes("ProjectService.Create", "projects:write"),
|
||||
server.WithEndpointScopes("ProjectService.Get", "projects:read"),
|
||||
server.WithEndpointScopes("ProjectService.List", "projects:read"),
|
||||
)
|
||||
|
||||
service.Handle(
|
||||
&TaskService{tasks: make(map[string]*Task)},
|
||||
server.WithEndpointScopes("TaskService.Create", "tasks:write"),
|
||||
server.WithEndpointScopes("TaskService.List", "tasks:read"),
|
||||
server.WithEndpointScopes("TaskService.Update", "tasks:write"),
|
||||
)
|
||||
|
||||
service.Handle(
|
||||
&TeamService{members: make(map[string]*Member)},
|
||||
server.WithEndpointScopes("TeamService.Add", "team:write"),
|
||||
server.WithEndpointScopes("TeamService.List", "team:read"),
|
||||
server.WithEndpointScopes("TeamService.Get", "team:read"),
|
||||
)
|
||||
|
||||
// Seed some demo data
|
||||
seedData(service.Server())
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Agent Demo")
|
||||
fmt.Println()
|
||||
fmt.Println(" MCP Gateway http://localhost:3000")
|
||||
fmt.Println(" MCP Tools http://localhost:3000/mcp/tools")
|
||||
fmt.Println(" WebSocket ws://localhost:3000/mcp/ws")
|
||||
fmt.Println()
|
||||
fmt.Println(" Try these prompts with Claude Code or the agent playground:")
|
||||
fmt.Println()
|
||||
fmt.Println(" \"What projects do we have?\"")
|
||||
fmt.Println(" \"Create a task for alice to design the new landing page\"")
|
||||
fmt.Println(" \"Show me all high-priority tasks that are still todo\"")
|
||||
fmt.Println(" \"Who on the team knows React?\"")
|
||||
fmt.Println(" \"Give me a status update on the Website Redesign project\"")
|
||||
fmt.Println()
|
||||
|
||||
service.Run()
|
||||
}
|
||||
|
||||
// seedData pre-populates the services with realistic demo data.
|
||||
func seedData(srv server.Server) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed team members
|
||||
team := &TeamService{members: make(map[string]*Member)}
|
||||
for _, m := range []AddMemberRequest{
|
||||
{Username: "alice", Name: "Alice Chen", Role: "engineer", Skills: []string{"go", "grpc", "kubernetes"}},
|
||||
{Username: "bob", Name: "Bob Park", Role: "designer", Skills: []string{"figma", "css", "react"}},
|
||||
{Username: "charlie", Name: "Charlie Kim", Role: "engineer", Skills: []string{"go", "react", "postgres"}},
|
||||
{Username: "diana", Name: "Diana Flores", Role: "manager", Skills: []string{"project-management", "scrum"}},
|
||||
} {
|
||||
req := m
|
||||
team.Add(ctx, &req, &AddMemberResponse{})
|
||||
}
|
||||
|
||||
// Seed projects
|
||||
projects := &ProjectService{projects: make(map[string]*Project)}
|
||||
projects.Create(ctx, &CreateProjectRequest{
|
||||
Name: "Website Redesign",
|
||||
Description: "Redesign the company website with new branding and improved UX",
|
||||
}, &CreateProjectResponse{})
|
||||
projects.projects["proj-1"].Status = "active"
|
||||
|
||||
projects.Create(ctx, &CreateProjectRequest{
|
||||
Name: "API v2 Migration",
|
||||
Description: "Migrate all services from REST to gRPC with backward compatibility",
|
||||
}, &CreateProjectResponse{})
|
||||
projects.projects["proj-2"].Status = "planning"
|
||||
|
||||
// Seed tasks
|
||||
tasks := &TaskService{tasks: make(map[string]*Task)}
|
||||
for _, t := range []CreateTaskRequest{
|
||||
{ProjectID: "proj-1", Title: "Design new homepage layout", Assignee: "bob", Priority: "high"},
|
||||
{ProjectID: "proj-1", Title: "Implement responsive nav component", Assignee: "charlie", Priority: "high"},
|
||||
{ProjectID: "proj-1", Title: "Write copy for about page", Priority: "medium"},
|
||||
{ProjectID: "proj-1", Title: "Set up CI/CD for new site", Assignee: "alice", Priority: "medium"},
|
||||
{ProjectID: "proj-2", Title: "Audit existing REST endpoints", Assignee: "alice", Priority: "high"},
|
||||
{ProjectID: "proj-2", Title: "Design gRPC proto files", Priority: "medium"},
|
||||
{ProjectID: "proj-2", Title: "Write migration guide", Assignee: "diana", Priority: "low"},
|
||||
} {
|
||||
req := t
|
||||
tasks.Create(ctx, &req, &CreateTaskResponse{})
|
||||
}
|
||||
// Mark a couple tasks as in_progress
|
||||
tasks.tasks["task-1"].Status = "in_progress"
|
||||
tasks.tasks["task-5"].Status = "in_progress"
|
||||
|
||||
// Register the seeded handlers (replace the empty ones registered above)
|
||||
// Note: in a real app these would be separate services. Here we register
|
||||
// pre-seeded instances so the demo starts with data.
|
||||
srv.Handle(srv.NewHandler(projects))
|
||||
srv.Handle(srv.NewHandler(tasks))
|
||||
srv.Handle(srv.NewHandler(team))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Multi-stage build for a go-micro service
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /service .
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk --no-cache add ca-certificates
|
||||
COPY --from=builder /service /service
|
||||
ENTRYPOINT ["/service"]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Standalone MCP gateway
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /gateway ./cmd/gateway
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk --no-cache add ca-certificates
|
||||
COPY --from=builder /gateway /gateway
|
||||
ENTRYPOINT ["/gateway"]
|
||||
@@ -0,0 +1,116 @@
|
||||
# Docker Compose Deployment Example
|
||||
|
||||
Run a go-micro service with MCP gateway, service registry, and distributed tracing in one command.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────┐ discover ┌──────────┐ RPC ┌─────────┐
|
||||
│ Agent │ ─────────────→ │ MCP │ ──────────→ │ Your │
|
||||
│ (Claude) │ MCP :3001 │ Gateway │ │ Service │
|
||||
└─────────┘ └──────────┘ └─────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ Consul │ │ Jaeger │
|
||||
│ Registry │ │ Tracing │
|
||||
│ :8500 │ │ :16686 │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| MCP Tools | http://localhost:3001/mcp/tools |
|
||||
| Consul UI | http://localhost:8500 |
|
||||
| Jaeger UI | http://localhost:16686 |
|
||||
| Service RPC | http://localhost:9090 |
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# List MCP tools
|
||||
curl http://localhost:3001/mcp/tools | jq
|
||||
|
||||
# Call a tool
|
||||
curl -X POST http://localhost:3001/mcp/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool": "myservice.Handler.Method", "arguments": {"key": "value"}}'
|
||||
|
||||
# View traces in Jaeger
|
||||
open http://localhost:16686
|
||||
```
|
||||
|
||||
## Connect Claude Code
|
||||
|
||||
```bash
|
||||
# Claude Code can connect to the running MCP gateway
|
||||
# Add to your Claude Code MCP settings:
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-services": {
|
||||
"url": "http://localhost:3001/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Customizing
|
||||
|
||||
### Add Your Service
|
||||
|
||||
Replace the `app` service's build context with your service directory:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
build:
|
||||
context: ../path/to/your/service
|
||||
dockerfile: Dockerfile
|
||||
```
|
||||
|
||||
### Add More Services
|
||||
|
||||
```yaml
|
||||
users:
|
||||
build: ./users
|
||||
environment:
|
||||
MICRO_REGISTRY: consul
|
||||
MICRO_REGISTRY_ADDRESS: consul:8500
|
||||
|
||||
orders:
|
||||
build: ./orders
|
||||
environment:
|
||||
MICRO_REGISTRY: consul
|
||||
MICRO_REGISTRY_ADDRESS: consul:8500
|
||||
```
|
||||
|
||||
All services register with Consul. The MCP gateway discovers them automatically.
|
||||
|
||||
### Add Redis Cache
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
```
|
||||
|
||||
Then set `MICRO_CACHE_ADDRESS=redis:6379` on your service.
|
||||
|
||||
### Production Considerations
|
||||
|
||||
- Add health checks to each service
|
||||
- Use named volumes for Consul data persistence
|
||||
- Configure rate limiting on the MCP gateway
|
||||
- Set up TLS between services
|
||||
- Use secrets management for API keys
|
||||
@@ -0,0 +1,65 @@
|
||||
# Go Micro + MCP Gateway deployment with Docker Compose
|
||||
#
|
||||
# This runs:
|
||||
# 1. Consul — service registry (discovery)
|
||||
# 2. App — your go-micro service(s)
|
||||
# 3. MCP Gateway — standalone MCP gateway connected to Consul
|
||||
# 4. Jaeger — distributed tracing UI
|
||||
#
|
||||
# Usage:
|
||||
# docker-compose up
|
||||
#
|
||||
# Endpoints:
|
||||
# MCP Tools: http://localhost:3001/mcp/tools
|
||||
# Consul UI: http://localhost:8500
|
||||
# Jaeger UI: http://localhost:16686
|
||||
# Service: http://localhost:9090 (RPC)
|
||||
|
||||
services:
|
||||
# --- Service Registry ---
|
||||
consul:
|
||||
image: consul:1.15
|
||||
ports:
|
||||
- "8500:8500"
|
||||
command: agent -server -bootstrap-expect=1 -ui -client=0.0.0.0
|
||||
|
||||
# --- Your Go Micro Service ---
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "9090:9090"
|
||||
environment:
|
||||
MICRO_REGISTRY: consul
|
||||
MICRO_REGISTRY_ADDRESS: consul:8500
|
||||
MICRO_SERVER_ADDRESS: :9090
|
||||
depends_on:
|
||||
- consul
|
||||
restart: unless-stopped
|
||||
|
||||
# --- MCP Gateway (standalone) ---
|
||||
mcp-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.gateway
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
MICRO_REGISTRY: consul
|
||||
MICRO_REGISTRY_ADDRESS: consul:8500
|
||||
MCP_ADDRESS: :3001
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4318
|
||||
depends_on:
|
||||
- consul
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
# --- Tracing ---
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:1.53
|
||||
ports:
|
||||
- "16686:16686" # UI
|
||||
- "4318:4318" # OTLP HTTP
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: "true"
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/client"
|
||||
)
|
||||
|
||||
// Request and Response types
|
||||
@@ -30,11 +29,7 @@ func (g *Greeter) Hello(ctx context.Context, req *Request, rsp *Response) error
|
||||
|
||||
func main() {
|
||||
// Create a new service
|
||||
service := micro.New(
|
||||
micro.Name("greeter"),
|
||||
micro.Version("latest"),
|
||||
micro.Address(":8080"),
|
||||
)
|
||||
service := micro.New("greeter", micro.Address(":8080"))
|
||||
|
||||
// Initialize the service
|
||||
service.Init()
|
||||
@@ -44,47 +39,17 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Run the service in a goroutine
|
||||
go func() {
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
fmt.Println("Starting greeter service on :8080")
|
||||
fmt.Println()
|
||||
fmt.Println("Test with:")
|
||||
fmt.Println(" curl -XPOST \\")
|
||||
fmt.Println(" -H 'Content-Type: application/json' \\")
|
||||
fmt.Println(" -H 'Micro-Endpoint: Greeter.Hello' \\")
|
||||
fmt.Println(" -d '{\"name\": \"Alice\"}' \\")
|
||||
fmt.Println(" http://localhost:8080")
|
||||
|
||||
// Wait for service to start
|
||||
fmt.Println("Service started on :8080")
|
||||
fmt.Println("Testing the service...")
|
||||
|
||||
// Create a client to test the service
|
||||
c := service.Client()
|
||||
|
||||
// Make a request
|
||||
req := c.NewRequest("greeter", "Greeter.Hello", &Request{Name: "World"})
|
||||
rsp := &Response{}
|
||||
|
||||
if err := c.Call(context.Background(), req, rsp); err != nil {
|
||||
log.Printf("Error calling service: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Response: %s\n", rsp.Message)
|
||||
// Run the service
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Make another request
|
||||
req2 := c.NewRequest("greeter", "Greeter.Hello", &Request{Name: "Go Micro"})
|
||||
rsp2 := &Response{}
|
||||
|
||||
if err := c.Call(context.Background(), req2, rsp2); err != nil {
|
||||
log.Printf("Error calling service: %v", err)
|
||||
} else {
|
||||
fmt.Printf("Response: %s\n", rsp2.Message)
|
||||
}
|
||||
|
||||
// Test with HTTP client
|
||||
fmt.Println("\nYou can also test with curl:")
|
||||
fmt.Println("curl -X POST http://localhost:8080 \\")
|
||||
fmt.Println(" -H 'Content-Type: application/json' \\")
|
||||
fmt.Println(" -H 'Micro-Endpoint: Greeter.Hello' \\")
|
||||
fmt.Println(" -d '{\"name\": \"Alice\"}'")
|
||||
|
||||
// Keep service running
|
||||
select {}
|
||||
}
|
||||
|
||||
+52
-4
@@ -19,6 +19,36 @@ cd hello
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### [crud](./crud/) - CRUD Contact Book
|
||||
|
||||
A realistic service with create, read, update, delete, list, and search operations. Shows how to document a full API for agents with `@example` tags, `description` struct tags, validation errors, and partial updates.
|
||||
|
||||
**Run it:**
|
||||
```bash
|
||||
cd crud
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### [workflow](./workflow/) - Cross-Service Orchestration
|
||||
|
||||
Three services (Inventory, Orders, Notifications) showing how an AI agent orchestrates multi-step workflows: search products, check stock, reserve inventory, place order, send confirmation — all from a single natural language request.
|
||||
|
||||
**Run it:**
|
||||
```bash
|
||||
cd workflow
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### [platform](./platform/) - Agent Platform Showcase
|
||||
|
||||
A complete platform (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog). Shows how existing microservices become agent-accessible with zero code changes — agents can sign up, write posts, comment, tag, and send mail through natural language.
|
||||
|
||||
**Run it:**
|
||||
```bash
|
||||
cd platform
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### [documented](./documented/) - Full-Featured Example
|
||||
|
||||
Complete example showing all MCP features with a user service.
|
||||
@@ -127,12 +157,30 @@ Just write Go comments - documentation is extracted automatically:
|
||||
### ✅ MCP Command Line
|
||||
|
||||
```bash
|
||||
micro mcp serve # Start with stdio
|
||||
micro mcp serve --address :3000 # Start with HTTP
|
||||
micro mcp list # List tools
|
||||
micro mcp test <tool-name> # Test a tool
|
||||
# Start MCP server
|
||||
micro mcp serve # Stdio (for Claude Code)
|
||||
micro mcp serve --address :3000 # HTTP/SSE (for web agents)
|
||||
|
||||
# List available tools
|
||||
micro mcp list # Human-readable list
|
||||
micro mcp list --json # JSON output
|
||||
|
||||
# Test a tool
|
||||
micro mcp test <tool-name> '{"key": "value"}'
|
||||
|
||||
# Generate documentation
|
||||
micro mcp docs # Markdown format
|
||||
micro mcp docs --format json # JSON format
|
||||
micro mcp docs --output tools.md # Save to file
|
||||
|
||||
# Export to different formats
|
||||
micro mcp export langchain # Python LangChain tools
|
||||
micro mcp export openapi # OpenAPI 3.0 spec
|
||||
micro mcp export json # Raw JSON definitions
|
||||
```
|
||||
|
||||
For detailed examples, see [CLI Examples](../../cmd/micro/mcp/EXAMPLES.md).
|
||||
|
||||
### ✅ Zero Configuration
|
||||
|
||||
- No manual tool registration
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# CRUD Contact Book Example
|
||||
|
||||
A complete CRUD service with MCP integration — the kind of service you'd actually build in production.
|
||||
|
||||
## What This Shows
|
||||
|
||||
- **6 operations**: Create, Get, Update, Delete, List, Search
|
||||
- **Rich documentation**: Every handler has doc comments with `@example` tags
|
||||
- **Struct tag descriptions**: All fields have `description` tags for agents
|
||||
- **Input validation**: Required field checks with clear error messages
|
||||
- **Partial updates**: Update only changes non-empty fields
|
||||
- **Seed data**: Starts with 3 contacts so agents can explore immediately
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# List all MCP tools
|
||||
curl http://localhost:3001/mcp/tools | jq
|
||||
|
||||
# Create a contact
|
||||
curl -X POST http://localhost:3001/mcp/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool": "contacts.Contacts.Create", "arguments": {"name": "Dave", "email": "dave@example.com"}}'
|
||||
|
||||
# Search contacts
|
||||
curl -X POST http://localhost:3001/mcp/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool": "contacts.Contacts.Search", "arguments": {"query": "engineer"}}'
|
||||
```
|
||||
|
||||
## Use with Claude Code
|
||||
|
||||
```bash
|
||||
micro mcp serve
|
||||
```
|
||||
|
||||
Then ask: "List all contacts and find the engineers."
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Doc Comments for Agents
|
||||
|
||||
```go
|
||||
// Create adds a new contact to the book. Name and email are required.
|
||||
//
|
||||
// @example {"name": "Dave Wilson", "email": "dave@example.com", "role": "Engineer"}
|
||||
func (h *Contacts) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
```
|
||||
|
||||
### Struct Tag Descriptions
|
||||
|
||||
```go
|
||||
type Contact struct {
|
||||
ID string `json:"id" description:"Unique contact identifier"`
|
||||
Name string `json:"name" description:"Full name"`
|
||||
Email string `json:"email" description:"Email address"`
|
||||
}
|
||||
```
|
||||
|
||||
### Partial Updates
|
||||
|
||||
Only update fields that are provided (non-empty), so agents can change one field without overwriting others:
|
||||
|
||||
```go
|
||||
if req.Name != "" {
|
||||
contact.Name = req.Name
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,278 @@
|
||||
// CRUD example: a contact book service with full MCP integration.
|
||||
//
|
||||
// This shows a realistic service with create, read, update, delete, and
|
||||
// search operations, all automatically exposed as MCP tools with rich
|
||||
// documentation for AI agents.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run .
|
||||
//
|
||||
// MCP tools: http://localhost:3001/mcp/tools
|
||||
// Test: curl http://localhost:3001/mcp/tools | jq
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
// --- Types ---
|
||||
|
||||
// Contact represents a person in the contact book.
|
||||
type Contact struct {
|
||||
ID string `json:"id" description:"Unique contact identifier"`
|
||||
Name string `json:"name" description:"Full name"`
|
||||
Email string `json:"email" description:"Email address"`
|
||||
Phone string `json:"phone" description:"Phone number in E.164 format"`
|
||||
Role string `json:"role" description:"Job title or role"`
|
||||
Notes string `json:"notes" description:"Free-text notes about this contact"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" description:"Full name (required)"`
|
||||
Email string `json:"email" description:"Email address (required)"`
|
||||
Phone string `json:"phone" description:"Phone number"`
|
||||
Role string `json:"role" description:"Job title or role"`
|
||||
Notes string `json:"notes" description:"Free-text notes"`
|
||||
}
|
||||
|
||||
type CreateResponse struct {
|
||||
Contact *Contact `json:"contact" description:"The newly created contact"`
|
||||
}
|
||||
|
||||
type GetRequest struct {
|
||||
ID string `json:"id" description:"Contact ID to look up"`
|
||||
}
|
||||
|
||||
type GetResponse struct {
|
||||
Contact *Contact `json:"contact" description:"The requested contact"`
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
ID string `json:"id" description:"Contact ID to update (required)"`
|
||||
Name string `json:"name" description:"New name (leave empty to keep current)"`
|
||||
Email string `json:"email" description:"New email (leave empty to keep current)"`
|
||||
Phone string `json:"phone" description:"New phone (leave empty to keep current)"`
|
||||
Role string `json:"role" description:"New role (leave empty to keep current)"`
|
||||
Notes string `json:"notes" description:"New notes (leave empty to keep current)"`
|
||||
}
|
||||
|
||||
type UpdateResponse struct {
|
||||
Contact *Contact `json:"contact" description:"The updated contact"`
|
||||
}
|
||||
|
||||
type DeleteRequest struct {
|
||||
ID string `json:"id" description:"Contact ID to delete"`
|
||||
}
|
||||
|
||||
type DeleteResponse struct {
|
||||
Deleted bool `json:"deleted" description:"True if the contact was deleted"`
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Contacts []*Contact `json:"contacts" description:"All contacts in the book"`
|
||||
}
|
||||
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query" description:"Search term to match against name, email, role, or notes"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Contacts []*Contact `json:"contacts" description:"Contacts matching the search query"`
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
// Contacts manages a contact book with CRUD operations.
|
||||
type Contacts struct {
|
||||
mu sync.RWMutex
|
||||
store map[string]*Contact
|
||||
counter int
|
||||
}
|
||||
|
||||
func NewContacts() *Contacts {
|
||||
c := &Contacts{store: make(map[string]*Contact)}
|
||||
// Seed with example data
|
||||
c.store["c-1"] = &Contact{ID: "c-1", Name: "Alice Johnson", Email: "alice@example.com", Phone: "+1-555-0101", Role: "Engineer", Notes: "Backend team lead"}
|
||||
c.store["c-2"] = &Contact{ID: "c-2", Name: "Bob Smith", Email: "bob@example.com", Phone: "+1-555-0102", Role: "Designer", Notes: "UI/UX specialist"}
|
||||
c.store["c-3"] = &Contact{ID: "c-3", Name: "Carol Davis", Email: "carol@example.com", Phone: "+1-555-0103", Role: "PM", Notes: "Leads the platform team"}
|
||||
c.counter = 3
|
||||
return c
|
||||
}
|
||||
|
||||
// Create adds a new contact to the book. Name and email are required.
|
||||
//
|
||||
// @example {"name": "Dave Wilson", "email": "dave@example.com", "role": "Engineer"}
|
||||
func (h *Contacts) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
if req.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if req.Email == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.counter++
|
||||
id := fmt.Sprintf("c-%d", h.counter)
|
||||
contact := &Contact{
|
||||
ID: id,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
Role: req.Role,
|
||||
Notes: req.Notes,
|
||||
}
|
||||
h.store[id] = contact
|
||||
rsp.Contact = contact
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a single contact by ID.
|
||||
//
|
||||
// @example {"id": "c-1"}
|
||||
func (h *Contacts) Get(ctx context.Context, req *GetRequest, rsp *GetResponse) error {
|
||||
if req.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
contact, ok := h.store[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("contact %s not found", req.ID)
|
||||
}
|
||||
rsp.Contact = contact
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing contact. Only non-empty fields are updated,
|
||||
// so you can change just the email without affecting other fields.
|
||||
//
|
||||
// @example {"id": "c-1", "role": "Senior Engineer"}
|
||||
func (h *Contacts) Update(ctx context.Context, req *UpdateRequest, rsp *UpdateResponse) error {
|
||||
if req.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
contact, ok := h.store[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("contact %s not found", req.ID)
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
contact.Name = req.Name
|
||||
}
|
||||
if req.Email != "" {
|
||||
contact.Email = req.Email
|
||||
}
|
||||
if req.Phone != "" {
|
||||
contact.Phone = req.Phone
|
||||
}
|
||||
if req.Role != "" {
|
||||
contact.Role = req.Role
|
||||
}
|
||||
if req.Notes != "" {
|
||||
contact.Notes = req.Notes
|
||||
}
|
||||
|
||||
rsp.Contact = contact
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a contact from the book permanently.
|
||||
//
|
||||
// @example {"id": "c-1"}
|
||||
func (h *Contacts) Delete(ctx context.Context, req *DeleteRequest, rsp *DeleteResponse) error {
|
||||
if req.ID == "" {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, ok := h.store[req.ID]; !ok {
|
||||
return fmt.Errorf("contact %s not found", req.ID)
|
||||
}
|
||||
|
||||
delete(h.store, req.ID)
|
||||
rsp.Deleted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all contacts in the book.
|
||||
//
|
||||
// @example {}
|
||||
func (h *Contacts) List(ctx context.Context, req *ListRequest, rsp *ListResponse) error {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for _, c := range h.store {
|
||||
rsp.Contacts = append(rsp.Contacts, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search finds contacts matching a query string. Matches against name,
|
||||
// email, role, and notes fields (case-insensitive).
|
||||
//
|
||||
// @example {"query": "engineer"}
|
||||
func (h *Contacts) Search(ctx context.Context, req *SearchRequest, rsp *SearchResponse) error {
|
||||
if req.Query == "" {
|
||||
return fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
q := strings.ToLower(req.Query)
|
||||
for _, c := range h.store {
|
||||
if strings.Contains(strings.ToLower(c.Name), q) ||
|
||||
strings.Contains(strings.ToLower(c.Email), q) ||
|
||||
strings.Contains(strings.ToLower(c.Role), q) ||
|
||||
strings.Contains(strings.ToLower(c.Notes), q) {
|
||||
rsp.Contacts = append(rsp.Contacts, c)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := micro.New("contacts",
|
||||
micro.Address(":9010"),
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
if err := service.Handle(NewContacts()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Contacts service running on :9010")
|
||||
fmt.Println("MCP tools available at http://localhost:3001/mcp/tools")
|
||||
fmt.Println()
|
||||
fmt.Println("Try asking an AI agent:")
|
||||
fmt.Println(" 'List all contacts'")
|
||||
fmt.Println(" 'Find engineers in the contact book'")
|
||||
fmt.Println(" 'Add a new contact for Eve at eve@example.com'")
|
||||
fmt.Println(" 'Update Alice's role to Staff Engineer'")
|
||||
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -90,53 +90,30 @@ func (u *Users) CreateUser(ctx context.Context, req *CreateUserRequest, rsp *Cre
|
||||
|
||||
func main() {
|
||||
// Create service
|
||||
service := micro.NewService(
|
||||
micro.Name("users"),
|
||||
micro.Version("1.0.0"),
|
||||
service := micro.New("users",
|
||||
micro.Address(":9090"),
|
||||
// Start MCP gateway alongside the service
|
||||
mcp.WithMCP(":3000"),
|
||||
)
|
||||
|
||||
service.Init()
|
||||
|
||||
// Register handler with pre-populated test data
|
||||
usersService := &Users{
|
||||
users: map[string]*User{
|
||||
"user-1": {
|
||||
ID: "user-1",
|
||||
Name: "John Doe",
|
||||
Email: "john@example.com",
|
||||
Age: 25,
|
||||
},
|
||||
"user-2": {
|
||||
ID: "user-2",
|
||||
Name: "Jane Smith",
|
||||
Email: "jane@example.com",
|
||||
Age: 30,
|
||||
// Register handler with pre-populated test data.
|
||||
// Documentation is automatically extracted from method comments.
|
||||
// Use WithEndpointScopes to declare required auth scopes per endpoint.
|
||||
if err := service.Handle(
|
||||
&Users{
|
||||
users: map[string]*User{
|
||||
"user-1": {ID: "user-1", Name: "John Doe", Email: "john@example.com", Age: 25},
|
||||
"user-2": {ID: "user-2", Name: "Jane Smith", Email: "jane@example.com", Age: 30},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Register handler - documentation is automatically extracted from method comments.
|
||||
// Use WithEndpointScopes to declare required auth scopes per endpoint.
|
||||
handler := service.Server().NewHandler(
|
||||
usersService,
|
||||
server.WithEndpointScopes("Users.GetUser", "users:read"),
|
||||
server.WithEndpointScopes("Users.CreateUser", "users:write"),
|
||||
)
|
||||
|
||||
if err := service.Server().Handle(handler); err != nil {
|
||||
); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Start MCP gateway on port 3000
|
||||
go func() {
|
||||
log.Println("Starting MCP gateway on :3000")
|
||||
if err := mcp.ListenAndServe(":3000", mcp.Options{
|
||||
Registry: service.Options().Registry,
|
||||
}); err != nil {
|
||||
log.Printf("MCP gateway error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("Users service starting...")
|
||||
log.Println("Service: users")
|
||||
log.Println("Endpoints:")
|
||||
|
||||
+10
-23
@@ -37,38 +37,25 @@ type HelloResponse struct {
|
||||
|
||||
func main() {
|
||||
// Create service
|
||||
service := micro.NewService(
|
||||
micro.Name("greeter"),
|
||||
micro.Version("1.0.0"),
|
||||
service := micro.New("greeter",
|
||||
micro.Address(":9090"),
|
||||
// Start MCP gateway alongside the service
|
||||
mcp.WithMCP(":3000"),
|
||||
)
|
||||
|
||||
service.Init()
|
||||
|
||||
// Register handler - documentation extracted automatically from comments!
|
||||
handler := service.Server().NewHandler(new(Greeter))
|
||||
if err := service.Server().Handle(handler); err != nil {
|
||||
// Register handler — docs extracted automatically from comments
|
||||
if err := service.Handle(new(Greeter)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Start MCP gateway on port 3000
|
||||
go func() {
|
||||
log.Println("Starting MCP gateway on :3000")
|
||||
if err := mcp.ListenAndServe(":3000", mcp.Options{
|
||||
Registry: service.Options().Registry,
|
||||
}); err != nil {
|
||||
log.Printf("MCP gateway error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("Greeter service starting...")
|
||||
log.Println("Service: greeter")
|
||||
log.Println("Endpoint: Greeter.SayHello")
|
||||
log.Println("Service: http://localhost:9090")
|
||||
log.Println("MCP Gateway: http://localhost:3000")
|
||||
log.Println("")
|
||||
log.Println("Test with:")
|
||||
log.Println(" curl http://localhost:3000/mcp/tools")
|
||||
log.Println("")
|
||||
log.Println("Or use with Claude Code:")
|
||||
log.Println("MCP Tools: http://localhost:3000/mcp/tools")
|
||||
log.Println()
|
||||
log.Println("Use with Claude Code:")
|
||||
log.Println(" micro mcp serve")
|
||||
|
||||
// Run service
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Platform Example: AI Agents Meet Real Microservices
|
||||
|
||||
This example mirrors the [micro/blog](https://github.com/micro/blog) platform — a real microblogging application built on Go Micro. It demonstrates how existing microservices become AI-accessible through MCP with **zero changes to business logic**.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Endpoints | Description |
|
||||
|---------|-----------|-------------|
|
||||
| **Users** | Signup, Login, GetProfile, UpdateStatus, List | Account management and authentication |
|
||||
| **Posts** | Create, Read, Update, Delete, List, TagPost, UntagPost, ListTags | Blog posts with markdown and tagging |
|
||||
| **Comments** | Create, List, Delete | Threaded comments on posts |
|
||||
| **Mail** | Send, Read | Internal messaging between users |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
MCP tools available at: http://localhost:3001/mcp/tools
|
||||
|
||||
## Agent Scenarios
|
||||
|
||||
These are realistic multi-step workflows an AI agent can complete:
|
||||
|
||||
### 1. New User Onboarding
|
||||
```
|
||||
"Sign up a new user called carol, then write a welcome post introducing herself"
|
||||
```
|
||||
The agent will: call Signup → use the returned user ID → call Posts.Create
|
||||
|
||||
### 2. Content Creation
|
||||
```
|
||||
"Log in as alice and write a blog post about Go concurrency patterns, then tag it with 'golang' and 'concurrency'"
|
||||
```
|
||||
The agent will: call Login → call Posts.Create → call TagPost twice
|
||||
|
||||
### 3. Social Interaction
|
||||
```
|
||||
"List all posts, find the welcome post, and comment on it as bob saying 'Great to be here!'"
|
||||
```
|
||||
The agent will: call Posts.List → pick the right post → call Comments.Create
|
||||
|
||||
### 4. Cross-Service Workflow
|
||||
```
|
||||
"Send a mail from alice to bob welcoming him, then check bob's inbox to confirm delivery"
|
||||
```
|
||||
The agent will: call Mail.Send → call Mail.Read to verify
|
||||
|
||||
### 5. Platform Overview
|
||||
```
|
||||
"Show me all users, all posts, and all tags currently in use"
|
||||
```
|
||||
The agent will: call Users.List, Posts.List, and ListTags (potentially in parallel)
|
||||
|
||||
## How It Works
|
||||
|
||||
The key insight: **you don't need to write any agent-specific code**. The MCP gateway discovers services from the registry, extracts tool schemas from Go types, and generates descriptions from doc comments.
|
||||
|
||||
```go
|
||||
service := micro.New("platform",
|
||||
micro.Address(":9090"),
|
||||
mcp.WithMCP(":3001"), // This one line makes everything AI-accessible
|
||||
)
|
||||
|
||||
service.Handle(users)
|
||||
service.Handle(posts)
|
||||
service.Handle(&CommentService{})
|
||||
service.Handle(&MailService{})
|
||||
```
|
||||
|
||||
Each handler method becomes an MCP tool. The `@example` tags in doc comments give agents sample inputs to learn from.
|
||||
|
||||
## Connecting to Claude Code
|
||||
|
||||
Add to your Claude Code MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"platform": {
|
||||
"command": "curl",
|
||||
"args": ["-s", "http://localhost:3001/mcp/tools"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use stdio transport:
|
||||
|
||||
```bash
|
||||
micro mcp serve --registry mdns
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Agent (Claude, GPT, etc.)
|
||||
│
|
||||
▼
|
||||
MCP Gateway (:3001) ← Discovers services, generates tools
|
||||
│
|
||||
▼
|
||||
Go Micro RPC (:9090) ← Standard service mesh
|
||||
│
|
||||
├── UserService ← Signup, Login, Profile
|
||||
├── PostService ← CRUD + Tags
|
||||
├── CommentService ← Threaded comments
|
||||
└── MailService ← Internal messaging
|
||||
```
|
||||
|
||||
## Relation to micro/blog
|
||||
|
||||
This example is a simplified, self-contained version of [micro/blog](https://github.com/micro/blog). The real platform splits each service into its own binary with protobuf definitions. This example uses Go structs directly for simplicity, but the MCP integration works identically either way — the gateway discovers services from the registry regardless of how they're implemented.
|
||||
@@ -0,0 +1,774 @@
|
||||
// Platform example: AI agents interacting with a real microservices platform.
|
||||
//
|
||||
// This example mirrors the micro/blog platform (https://github.com/micro/blog)
|
||||
// — a microblogging platform built on Go Micro with Users, Posts, Comments,
|
||||
// and Mail services. It demonstrates how existing microservices become
|
||||
// AI-accessible through MCP with zero changes to business logic.
|
||||
//
|
||||
// The services run as a single binary for convenience. In production,
|
||||
// each would be a separate process discovered via the registry.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run .
|
||||
//
|
||||
// MCP tools: http://localhost:3001/mcp/tools
|
||||
//
|
||||
// Agent scenarios:
|
||||
//
|
||||
// "Sign me up as alice with password secret123"
|
||||
// "Log in as alice and write a blog post about Go concurrency"
|
||||
// "List all posts and comment on the first one"
|
||||
// "Send a welcome email to alice"
|
||||
// "Tag the Go concurrency post with 'golang' and 'tutorial'"
|
||||
// "Show me alice's profile and all her posts"
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
"go-micro.dev/v5/server"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users service — account registration, login, profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" description:"Unique user identifier"`
|
||||
Name string `json:"name" description:"Display name"`
|
||||
Status string `json:"status" description:"Bio or status message"`
|
||||
CreatedAt int64 `json:"created_at" description:"Unix timestamp of account creation"`
|
||||
}
|
||||
|
||||
type SignupRequest struct {
|
||||
Name string `json:"name" description:"Username (required, 3-20 characters)"`
|
||||
Password string `json:"password" description:"Password (required, minimum 6 characters)"`
|
||||
}
|
||||
type SignupResponse struct {
|
||||
User *User `json:"user" description:"The newly created user account"`
|
||||
Token string `json:"token" description:"Session token for authenticated requests"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Name string `json:"name" description:"Username"`
|
||||
Password string `json:"password" description:"Password"`
|
||||
}
|
||||
type LoginResponse struct {
|
||||
User *User `json:"user" description:"The authenticated user"`
|
||||
Token string `json:"token" description:"Session token for authenticated requests"`
|
||||
}
|
||||
|
||||
type GetProfileRequest struct {
|
||||
ID string `json:"id" description:"User ID to look up"`
|
||||
}
|
||||
type GetProfileResponse struct {
|
||||
User *User `json:"user" description:"The user profile"`
|
||||
}
|
||||
|
||||
type UpdateStatusRequest struct {
|
||||
ID string `json:"id" description:"User ID"`
|
||||
Status string `json:"status" description:"New bio or status message"`
|
||||
}
|
||||
type UpdateStatusResponse struct {
|
||||
User *User `json:"user" description:"Updated user profile"`
|
||||
}
|
||||
|
||||
type ListUsersRequest struct{}
|
||||
type ListUsersResponse struct {
|
||||
Users []*User `json:"users" description:"All registered users"`
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*User
|
||||
passwords map[string]string // name -> password (plaintext for demo only)
|
||||
tokens map[string]string // token -> user ID
|
||||
nextID int
|
||||
}
|
||||
|
||||
func NewUsers() *Users {
|
||||
return &Users{
|
||||
users: make(map[string]*User),
|
||||
passwords: make(map[string]string),
|
||||
tokens: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Signup creates a new user account and returns a session token.
|
||||
// The username must be unique. Use the returned token for authenticated operations.
|
||||
//
|
||||
// @example {"name": "alice", "password": "secret123"}
|
||||
func (s *Users) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error {
|
||||
if req.Name == "" || len(req.Name) < 3 {
|
||||
return fmt.Errorf("name must be at least 3 characters")
|
||||
}
|
||||
if req.Password == "" || len(req.Password) < 6 {
|
||||
return fmt.Errorf("password must be at least 6 characters")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check uniqueness
|
||||
for _, u := range s.users {
|
||||
if strings.EqualFold(u.Name, req.Name) {
|
||||
return fmt.Errorf("username %q is already taken", req.Name)
|
||||
}
|
||||
}
|
||||
|
||||
s.nextID++
|
||||
user := &User{
|
||||
ID: fmt.Sprintf("user-%d", s.nextID),
|
||||
Name: req.Name,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
s.users[user.ID] = user
|
||||
s.passwords[req.Name] = req.Password
|
||||
|
||||
token := generateToken()
|
||||
s.tokens[token] = user.ID
|
||||
|
||||
rsp.User = user
|
||||
rsp.Token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a session token.
|
||||
// Returns an error if the credentials are invalid.
|
||||
//
|
||||
// @example {"name": "alice", "password": "secret123"}
|
||||
func (s *Users) Login(ctx context.Context, req *LoginRequest, rsp *LoginResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
pass, ok := s.passwords[req.Name]
|
||||
if !ok || pass != req.Password {
|
||||
return fmt.Errorf("invalid username or password")
|
||||
}
|
||||
|
||||
// Find user by name
|
||||
for _, u := range s.users {
|
||||
if u.Name == req.Name {
|
||||
token := generateToken()
|
||||
s.tokens[token] = u.ID
|
||||
rsp.User = u
|
||||
rsp.Token = token
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// GetProfile retrieves a user's public profile by ID.
|
||||
//
|
||||
// @example {"id": "user-1"}
|
||||
func (s *Users) GetProfile(ctx context.Context, req *GetProfileRequest, rsp *GetProfileResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
u, ok := s.users[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("user %s not found", req.ID)
|
||||
}
|
||||
rsp.User = u
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus sets a user's bio or status message.
|
||||
//
|
||||
// @example {"id": "user-1", "status": "Writing about Go and microservices"}
|
||||
func (s *Users) UpdateStatus(ctx context.Context, req *UpdateStatusRequest, rsp *UpdateStatusResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
u, ok := s.users[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("user %s not found", req.ID)
|
||||
}
|
||||
u.Status = req.Status
|
||||
rsp.User = u
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all registered users on the platform.
|
||||
//
|
||||
// @example {}
|
||||
func (s *Users) List(ctx context.Context, req *ListUsersRequest, rsp *ListUsersResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, u := range s.users {
|
||||
rsp.Users = append(rsp.Users, u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Posts service — blog posts with markdown and tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id" description:"Unique post identifier"`
|
||||
Title string `json:"title" description:"Post title"`
|
||||
Content string `json:"content" description:"Post body in markdown"`
|
||||
AuthorID string `json:"author_id" description:"ID of the post author"`
|
||||
AuthorName string `json:"author_name" description:"Display name of the author"`
|
||||
Tags []string `json:"tags,omitempty" description:"Post tags for categorization"`
|
||||
CreatedAt int64 `json:"created_at" description:"Unix timestamp of creation"`
|
||||
UpdatedAt int64 `json:"updated_at" description:"Unix timestamp of last update"`
|
||||
}
|
||||
|
||||
type CreatePostRequest struct {
|
||||
Title string `json:"title" description:"Post title (required)"`
|
||||
Content string `json:"content" description:"Post body in markdown (required)"`
|
||||
AuthorID string `json:"author_id" description:"Author's user ID (required)"`
|
||||
AuthorName string `json:"author_name" description:"Author's display name (required)"`
|
||||
}
|
||||
type CreatePostResponse struct {
|
||||
Post *Post `json:"post" description:"The newly created post"`
|
||||
}
|
||||
|
||||
type ReadPostRequest struct {
|
||||
ID string `json:"id" description:"Post ID to retrieve"`
|
||||
}
|
||||
type ReadPostResponse struct {
|
||||
Post *Post `json:"post" description:"The requested post"`
|
||||
}
|
||||
|
||||
type UpdatePostRequest struct {
|
||||
ID string `json:"id" description:"Post ID to update (required)"`
|
||||
Title string `json:"title" description:"New title"`
|
||||
Content string `json:"content" description:"New content in markdown"`
|
||||
}
|
||||
type UpdatePostResponse struct {
|
||||
Post *Post `json:"post" description:"The updated post"`
|
||||
}
|
||||
|
||||
type DeletePostRequest struct {
|
||||
ID string `json:"id" description:"Post ID to delete"`
|
||||
}
|
||||
type DeletePostResponse struct {
|
||||
Message string `json:"message" description:"Confirmation message"`
|
||||
}
|
||||
|
||||
type ListPostsRequest struct {
|
||||
AuthorID string `json:"author_id,omitempty" description:"Filter by author ID (optional)"`
|
||||
}
|
||||
type ListPostsResponse struct {
|
||||
Posts []*Post `json:"posts" description:"Posts in reverse chronological order"`
|
||||
Total int `json:"total" description:"Total number of matching posts"`
|
||||
}
|
||||
|
||||
type TagPostRequest struct {
|
||||
PostID string `json:"post_id" description:"Post to tag"`
|
||||
Tag string `json:"tag" description:"Tag to add (lowercase, no spaces)"`
|
||||
}
|
||||
type TagPostResponse struct {
|
||||
Post *Post `json:"post" description:"Post with updated tags"`
|
||||
}
|
||||
|
||||
type UntagPostRequest struct {
|
||||
PostID string `json:"post_id" description:"Post to untag"`
|
||||
Tag string `json:"tag" description:"Tag to remove"`
|
||||
}
|
||||
type UntagPostResponse struct {
|
||||
Post *Post `json:"post" description:"Post with updated tags"`
|
||||
}
|
||||
|
||||
type ListTagsRequest struct{}
|
||||
type ListTagsResponse struct {
|
||||
Tags []string `json:"tags" description:"All tags in use, sorted alphabetically"`
|
||||
}
|
||||
|
||||
type Posts struct {
|
||||
mu sync.RWMutex
|
||||
posts map[string]*Post
|
||||
nextID int
|
||||
}
|
||||
|
||||
func NewPosts() *Posts {
|
||||
return &Posts{posts: make(map[string]*Post)}
|
||||
}
|
||||
|
||||
// Create publishes a new blog post. Title, content, author_id, and author_name
|
||||
// are required. Content supports markdown formatting.
|
||||
//
|
||||
// @example {"title": "Getting Started with Go Micro", "content": "Go Micro makes it easy to build microservices...", "author_id": "user-1", "author_name": "alice"}
|
||||
func (s *Posts) Create(ctx context.Context, req *CreatePostRequest, rsp *CreatePostResponse) error {
|
||||
if req.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if req.Content == "" {
|
||||
return fmt.Errorf("content is required")
|
||||
}
|
||||
if req.AuthorID == "" {
|
||||
return fmt.Errorf("author_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.nextID++
|
||||
now := time.Now().Unix()
|
||||
post := &Post{
|
||||
ID: fmt.Sprintf("post-%d", s.nextID),
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
AuthorID: req.AuthorID,
|
||||
AuthorName: req.AuthorName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.posts[post.ID] = post
|
||||
rsp.Post = post
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read retrieves a single blog post by ID.
|
||||
//
|
||||
// @example {"id": "post-1"}
|
||||
func (s *Posts) Read(ctx context.Context, req *ReadPostRequest, rsp *ReadPostResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
p, ok := s.posts[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("post %s not found", req.ID)
|
||||
}
|
||||
rsp.Post = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies a blog post's title and/or content.
|
||||
// Only non-empty fields are updated.
|
||||
//
|
||||
// @example {"id": "post-1", "title": "Updated Title", "content": "New content here..."}
|
||||
func (s *Posts) Update(ctx context.Context, req *UpdatePostRequest, rsp *UpdatePostResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
p, ok := s.posts[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("post %s not found", req.ID)
|
||||
}
|
||||
if req.Title != "" {
|
||||
p.Title = req.Title
|
||||
}
|
||||
if req.Content != "" {
|
||||
p.Content = req.Content
|
||||
}
|
||||
p.UpdatedAt = time.Now().Unix()
|
||||
rsp.Post = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a blog post permanently.
|
||||
//
|
||||
// @example {"id": "post-1"}
|
||||
func (s *Posts) Delete(ctx context.Context, req *DeletePostRequest, rsp *DeletePostResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.posts[req.ID]; !ok {
|
||||
return fmt.Errorf("post %s not found", req.ID)
|
||||
}
|
||||
delete(s.posts, req.ID)
|
||||
rsp.Message = fmt.Sprintf("post %s deleted", req.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns blog posts in reverse chronological order.
|
||||
// Optionally filter by author_id to see a specific user's posts.
|
||||
//
|
||||
// @example {"author_id": "user-1"}
|
||||
func (s *Posts) List(ctx context.Context, req *ListPostsRequest, rsp *ListPostsResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, p := range s.posts {
|
||||
if req.AuthorID != "" && p.AuthorID != req.AuthorID {
|
||||
continue
|
||||
}
|
||||
rsp.Posts = append(rsp.Posts, p)
|
||||
}
|
||||
sort.Slice(rsp.Posts, func(i, j int) bool {
|
||||
return rsp.Posts[i].CreatedAt > rsp.Posts[j].CreatedAt
|
||||
})
|
||||
rsp.Total = len(rsp.Posts)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TagPost adds a tag to a post. Tags are useful for categorization
|
||||
// and discovery. Duplicate tags are ignored.
|
||||
//
|
||||
// @example {"post_id": "post-1", "tag": "golang"}
|
||||
func (s *Posts) TagPost(ctx context.Context, req *TagPostRequest, rsp *TagPostResponse) error {
|
||||
if req.PostID == "" || req.Tag == "" {
|
||||
return fmt.Errorf("post_id and tag are required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
p, ok := s.posts[req.PostID]
|
||||
if !ok {
|
||||
return fmt.Errorf("post %s not found", req.PostID)
|
||||
}
|
||||
|
||||
tag := strings.ToLower(strings.TrimSpace(req.Tag))
|
||||
for _, t := range p.Tags {
|
||||
if t == tag {
|
||||
rsp.Post = p
|
||||
return nil
|
||||
}
|
||||
}
|
||||
p.Tags = append(p.Tags, tag)
|
||||
p.UpdatedAt = time.Now().Unix()
|
||||
rsp.Post = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// UntagPost removes a tag from a post.
|
||||
//
|
||||
// @example {"post_id": "post-1", "tag": "golang"}
|
||||
func (s *Posts) UntagPost(ctx context.Context, req *UntagPostRequest, rsp *UntagPostResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
p, ok := s.posts[req.PostID]
|
||||
if !ok {
|
||||
return fmt.Errorf("post %s not found", req.PostID)
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(p.Tags))
|
||||
for _, t := range p.Tags {
|
||||
if t != req.Tag {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
p.Tags = filtered
|
||||
p.UpdatedAt = time.Now().Unix()
|
||||
rsp.Post = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTags returns all tags currently in use across all posts.
|
||||
//
|
||||
// @example {}
|
||||
func (s *Posts) ListTags(ctx context.Context, req *ListTagsRequest, rsp *ListTagsResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range s.posts {
|
||||
for _, t := range p.Tags {
|
||||
seen[t] = true
|
||||
}
|
||||
}
|
||||
for t := range seen {
|
||||
rsp.Tags = append(rsp.Tags, t)
|
||||
}
|
||||
sort.Strings(rsp.Tags)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comments service — threaded comments on posts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Comment struct {
|
||||
ID string `json:"id" description:"Unique comment identifier"`
|
||||
PostID string `json:"post_id" description:"ID of the post this comment belongs to"`
|
||||
Content string `json:"content" description:"Comment text"`
|
||||
AuthorID string `json:"author_id" description:"ID of the comment author"`
|
||||
AuthorName string `json:"author_name" description:"Display name of the author"`
|
||||
CreatedAt int64 `json:"created_at" description:"Unix timestamp of creation"`
|
||||
}
|
||||
|
||||
type CreateCommentRequest struct {
|
||||
PostID string `json:"post_id" description:"Post to comment on (required)"`
|
||||
Content string `json:"content" description:"Comment text (required)"`
|
||||
AuthorID string `json:"author_id" description:"Author's user ID (required)"`
|
||||
AuthorName string `json:"author_name" description:"Author's display name (required)"`
|
||||
}
|
||||
type CreateCommentResponse struct {
|
||||
Comment *Comment `json:"comment" description:"The newly created comment"`
|
||||
}
|
||||
|
||||
type ListCommentsRequest struct {
|
||||
PostID string `json:"post_id,omitempty" description:"Filter by post ID (optional)"`
|
||||
AuthorID string `json:"author_id,omitempty" description:"Filter by author ID (optional)"`
|
||||
}
|
||||
type ListCommentsResponse struct {
|
||||
Comments []*Comment `json:"comments" description:"Matching comments"`
|
||||
}
|
||||
|
||||
type DeleteCommentRequest struct {
|
||||
ID string `json:"id" description:"Comment ID to delete"`
|
||||
}
|
||||
type DeleteCommentResponse struct {
|
||||
Message string `json:"message" description:"Confirmation message"`
|
||||
}
|
||||
|
||||
type Comments struct {
|
||||
mu sync.RWMutex
|
||||
comments []*Comment
|
||||
nextID int
|
||||
}
|
||||
|
||||
// Create adds a comment to a blog post. Post ID, content, author_id,
|
||||
// and author_name are all required.
|
||||
//
|
||||
// @example {"post_id": "post-1", "content": "Great article! Very helpful.", "author_id": "user-2", "author_name": "bob"}
|
||||
func (s *Comments) Create(ctx context.Context, req *CreateCommentRequest, rsp *CreateCommentResponse) error {
|
||||
if req.PostID == "" {
|
||||
return fmt.Errorf("post_id is required")
|
||||
}
|
||||
if req.Content == "" {
|
||||
return fmt.Errorf("content is required")
|
||||
}
|
||||
if req.AuthorID == "" {
|
||||
return fmt.Errorf("author_id is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.nextID++
|
||||
comment := &Comment{
|
||||
ID: fmt.Sprintf("comment-%d", s.nextID),
|
||||
PostID: req.PostID,
|
||||
Content: req.Content,
|
||||
AuthorID: req.AuthorID,
|
||||
AuthorName: req.AuthorName,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
s.comments = append(s.comments, comment)
|
||||
rsp.Comment = comment
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns comments, optionally filtered by post or author.
|
||||
// Use post_id to get all comments on a specific post.
|
||||
//
|
||||
// @example {"post_id": "post-1"}
|
||||
func (s *Comments) List(ctx context.Context, req *ListCommentsRequest, rsp *ListCommentsResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, c := range s.comments {
|
||||
if req.PostID != "" && c.PostID != req.PostID {
|
||||
continue
|
||||
}
|
||||
if req.AuthorID != "" && c.AuthorID != req.AuthorID {
|
||||
continue
|
||||
}
|
||||
rsp.Comments = append(rsp.Comments, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a comment by ID.
|
||||
//
|
||||
// @example {"id": "comment-1"}
|
||||
func (s *Comments) Delete(ctx context.Context, req *DeleteCommentRequest, rsp *DeleteCommentResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, c := range s.comments {
|
||||
if c.ID == req.ID {
|
||||
s.comments = append(s.comments[:i], s.comments[i+1:]...)
|
||||
rsp.Message = fmt.Sprintf("comment %s deleted", req.ID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("comment %s not found", req.ID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mail service — internal messaging between users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id" description:"Unique message identifier"`
|
||||
From string `json:"from" description:"Sender username"`
|
||||
To string `json:"to" description:"Recipient username"`
|
||||
Subject string `json:"subject" description:"Message subject line"`
|
||||
Body string `json:"body" description:"Message body text"`
|
||||
Read bool `json:"read" description:"Whether the message has been read"`
|
||||
CreatedAt int64 `json:"created_at" description:"Unix timestamp of when the message was sent"`
|
||||
}
|
||||
|
||||
type SendMailRequest struct {
|
||||
From string `json:"from" description:"Sender username (required)"`
|
||||
To string `json:"to" description:"Recipient username (required)"`
|
||||
Subject string `json:"subject" description:"Message subject (required)"`
|
||||
Body string `json:"body" description:"Message body (required)"`
|
||||
}
|
||||
type SendMailResponse struct {
|
||||
Message *MailMessage `json:"message" description:"The sent message"`
|
||||
}
|
||||
|
||||
type ReadMailRequest struct {
|
||||
User string `json:"user" description:"Username to read inbox for"`
|
||||
}
|
||||
type ReadMailResponse struct {
|
||||
Messages []*MailMessage `json:"messages" description:"Inbox messages, newest first"`
|
||||
}
|
||||
|
||||
type Mail struct {
|
||||
mu sync.RWMutex
|
||||
messages []*MailMessage
|
||||
nextID int
|
||||
}
|
||||
|
||||
// Send delivers a message to another user on the platform.
|
||||
// Both sender and recipient are identified by username.
|
||||
//
|
||||
// @example {"from": "alice", "to": "bob", "subject": "Welcome!", "body": "Hey Bob, welcome to the platform!"}
|
||||
func (s *Mail) Send(ctx context.Context, req *SendMailRequest, rsp *SendMailResponse) error {
|
||||
if req.From == "" || req.To == "" {
|
||||
return fmt.Errorf("from and to are required")
|
||||
}
|
||||
if req.Subject == "" {
|
||||
return fmt.Errorf("subject is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.nextID++
|
||||
msg := &MailMessage{
|
||||
ID: fmt.Sprintf("mail-%d", s.nextID),
|
||||
From: req.From,
|
||||
To: req.To,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
s.messages = append(s.messages, msg)
|
||||
rsp.Message = msg
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read returns all messages in a user's inbox, newest first.
|
||||
//
|
||||
// @example {"user": "alice"}
|
||||
func (s *Mail) Read(ctx context.Context, req *ReadMailRequest, rsp *ReadMailResponse) error {
|
||||
if req.User == "" {
|
||||
return fmt.Errorf("user is required")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i := len(s.messages) - 1; i >= 0; i-- {
|
||||
if s.messages[i].To == req.User {
|
||||
s.messages[i].Read = true
|
||||
rsp.Messages = append(rsp.Messages, s.messages[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main — wire up all services with MCP gateway
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
service := micro.New("platform",
|
||||
micro.Address(":9090"),
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
users := NewUsers()
|
||||
posts := NewPosts()
|
||||
|
||||
// Seed some demo data so agents have something to work with
|
||||
seedData(users, posts)
|
||||
|
||||
service.Handle(users)
|
||||
service.Handle(posts)
|
||||
service.Handle(&Comments{})
|
||||
service.Handle(&Mail{},
|
||||
server.WithEndpointScopes("Mail.Send", "mail:write"),
|
||||
server.WithEndpointScopes("Mail.Read", "mail:read"),
|
||||
)
|
||||
|
||||
printBanner()
|
||||
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedData(users *Users, posts *Posts) {
|
||||
// Create demo users
|
||||
var aliceRsp SignupResponse
|
||||
users.Signup(context.Background(), &SignupRequest{
|
||||
Name: "alice", Password: "secret123",
|
||||
}, &aliceRsp)
|
||||
|
||||
var bobRsp SignupResponse
|
||||
users.Signup(context.Background(), &SignupRequest{
|
||||
Name: "bob", Password: "secret123",
|
||||
}, &bobRsp)
|
||||
|
||||
// Alice writes a welcome post
|
||||
var postRsp CreatePostResponse
|
||||
posts.Create(context.Background(), &CreatePostRequest{
|
||||
Title: "Welcome to the Platform",
|
||||
Content: "This is the first post on our new blogging platform. Built with Go Micro, every service is automatically accessible to AI agents through MCP.",
|
||||
AuthorID: aliceRsp.User.ID,
|
||||
AuthorName: "alice",
|
||||
}, &postRsp)
|
||||
|
||||
// Tag it
|
||||
posts.TagPost(context.Background(), &TagPostRequest{
|
||||
PostID: postRsp.Post.ID, Tag: "welcome",
|
||||
}, &TagPostResponse{})
|
||||
posts.TagPost(context.Background(), &TagPostRequest{
|
||||
PostID: postRsp.Post.ID, Tag: "go-micro",
|
||||
}, &TagPostResponse{})
|
||||
}
|
||||
|
||||
func printBanner() {
|
||||
fmt.Println()
|
||||
fmt.Println(" Platform Demo — AI-Native Microservices")
|
||||
fmt.Println()
|
||||
fmt.Println(" Services: Users, Posts, Comments, Mail")
|
||||
fmt.Println(" MCP Tools: http://localhost:3001/mcp/tools")
|
||||
fmt.Println(" RPC: localhost:9090")
|
||||
fmt.Println()
|
||||
fmt.Println(" Seeded: alice (user-1), bob (user-2)")
|
||||
fmt.Println(" 1 post with tags [welcome, go-micro]")
|
||||
fmt.Println()
|
||||
fmt.Println(" Try asking an agent:")
|
||||
fmt.Println()
|
||||
fmt.Println(` "Sign up a new user called carol"`)
|
||||
fmt.Println(` "Log in as alice and write a post about Go concurrency patterns"`)
|
||||
fmt.Println(` "List all posts and comment on the welcome post as bob"`)
|
||||
fmt.Println(` "Tag alice's post with 'tutorial' and 'golang'"`)
|
||||
fmt.Println(` "Send a mail from alice to bob welcoming him to the platform"`)
|
||||
fmt.Println(` "Show me bob's inbox"`)
|
||||
fmt.Println(` "List all users and show me all tags in use"`)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func generateToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Workflow Example: Cross-Service Orchestration
|
||||
|
||||
An e-commerce scenario with three services (Inventory, Orders, Notifications) that demonstrates how AI agents orchestrate multi-step workflows across services — no glue code, no workflow engine.
|
||||
|
||||
## The Workflow
|
||||
|
||||
When a user says _"Order a ThinkPad for alice and send her a confirmation"_, the agent figures out the steps:
|
||||
|
||||
```
|
||||
1. InventoryService.Search → Find the product
|
||||
2. InventoryService.CheckStock → Verify availability
|
||||
3. InventoryService.ReserveStock → Decrement inventory
|
||||
4. OrderService.PlaceOrder → Create the order
|
||||
5. NotificationService.Send → Email confirmation
|
||||
```
|
||||
|
||||
No code connects these steps — the agent reads the tool descriptions and chains the calls itself.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Tools | Purpose |
|
||||
|---------|-------|---------|
|
||||
| InventoryService | Search, CheckStock, ReserveStock | Product catalog and stock management |
|
||||
| OrderService | PlaceOrder, GetOrder, ListOrders | Order creation and lookup |
|
||||
| NotificationService | Send, List | Email/SMS/Slack notifications |
|
||||
|
||||
## Example Prompts
|
||||
|
||||
Try these with Claude Code (`micro mcp serve`) or any MCP-compatible agent:
|
||||
|
||||
- "What laptops do you have in stock?"
|
||||
- "Order a ThinkPad for alice@example.com and send her a confirmation"
|
||||
- "Check if 'The Go Programming Language' is available" (it's out of stock!)
|
||||
- "Order 3 Go Gopher t-shirts for bob@example.com, reserve the stock, and notify him via Slack"
|
||||
- "Show me all orders and notifications for alice"
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Traditional approach:
|
||||
```go
|
||||
// 50+ lines of glue code wiring services together
|
||||
func handleOrder(req OrderRequest) {
|
||||
product, err := inventoryClient.CheckStock(req.SKU)
|
||||
if err != nil { ... }
|
||||
if product.InStock < req.Quantity { ... }
|
||||
_, err = inventoryClient.ReserveStock(req.SKU, req.Quantity)
|
||||
if err != nil { ... }
|
||||
order, err := orderClient.PlaceOrder(...)
|
||||
if err != nil { ... }
|
||||
_, err = notificationClient.Send(...)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Agent approach:
|
||||
```
|
||||
User: "Order a ThinkPad for alice and confirm via email"
|
||||
Agent: [reads tool descriptions, chains 5 calls, handles the out-of-stock case]
|
||||
```
|
||||
|
||||
The agent handles the orchestration. You just write the individual services with good documentation.
|
||||
@@ -0,0 +1,393 @@
|
||||
// Workflow example: cross-service orchestration via AI agents.
|
||||
//
|
||||
// This example runs three services (Inventory, Orders, Notifications) and
|
||||
// demonstrates how an AI agent can orchestrate a multi-step workflow:
|
||||
//
|
||||
// 1. Check inventory for a product
|
||||
// 2. Place an order if in stock
|
||||
// 3. Send a confirmation notification
|
||||
//
|
||||
// The agent figures out the right sequence of calls on its own — no
|
||||
// workflow engine, no glue code, just natural language.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run .
|
||||
//
|
||||
// MCP tools: http://localhost:3001/mcp/tools
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
"go-micro.dev/v5/gateway/mcp"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Product struct {
|
||||
SKU string `json:"sku" description:"Stock keeping unit identifier"`
|
||||
Name string `json:"name" description:"Product name"`
|
||||
Price float64 `json:"price" description:"Unit price in USD"`
|
||||
InStock int `json:"in_stock" description:"Number of units available"`
|
||||
Category string `json:"category" description:"Product category"`
|
||||
}
|
||||
|
||||
type CheckStockRequest struct {
|
||||
SKU string `json:"sku" description:"Product SKU to check"`
|
||||
}
|
||||
|
||||
type CheckStockResponse struct {
|
||||
Product *Product `json:"product" description:"Product details with current stock level"`
|
||||
}
|
||||
|
||||
type SearchProductsRequest struct {
|
||||
Query string `json:"query" description:"Search term to match against product name or category"`
|
||||
Category string `json:"category,omitempty" description:"Filter by category: electronics, clothing, books (optional)"`
|
||||
}
|
||||
|
||||
type SearchProductsResponse struct {
|
||||
Products []*Product `json:"products" description:"Products matching the search criteria"`
|
||||
}
|
||||
|
||||
type ReserveStockRequest struct {
|
||||
SKU string `json:"sku" description:"Product SKU to reserve"`
|
||||
Quantity int `json:"quantity" description:"Number of units to reserve"`
|
||||
}
|
||||
|
||||
type ReserveStockResponse struct {
|
||||
Reserved bool `json:"reserved" description:"True if stock was successfully reserved"`
|
||||
Remaining int `json:"remaining" description:"Units remaining after reservation"`
|
||||
Message string `json:"message" description:"Human-readable result message"`
|
||||
}
|
||||
|
||||
type InventoryService struct {
|
||||
mu sync.RWMutex
|
||||
products map[string]*Product
|
||||
}
|
||||
|
||||
// CheckStock returns the current stock level for a product.
|
||||
// Use this before placing an order to verify availability.
|
||||
//
|
||||
// @example {"sku": "LAPTOP-001"}
|
||||
func (s *InventoryService) CheckStock(ctx context.Context, req *CheckStockRequest, rsp *CheckStockResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, ok := s.products[req.SKU]
|
||||
if !ok {
|
||||
return fmt.Errorf("product %s not found", req.SKU)
|
||||
}
|
||||
rsp.Product = p
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search finds products by name or category. Use this to help
|
||||
// users find what they're looking for before checking stock.
|
||||
//
|
||||
// @example {"query": "laptop"}
|
||||
func (s *InventoryService) Search(ctx context.Context, req *SearchProductsRequest, rsp *SearchProductsResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
q := strings.ToLower(req.Query)
|
||||
for _, p := range s.products {
|
||||
if req.Category != "" && !strings.EqualFold(p.Category, req.Category) {
|
||||
continue
|
||||
}
|
||||
if q == "" || strings.Contains(strings.ToLower(p.Name), q) || strings.Contains(strings.ToLower(p.Category), q) {
|
||||
rsp.Products = append(rsp.Products, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReserveStock decrements inventory for a product. Call this after
|
||||
// confirming stock is available. Returns an error if insufficient stock.
|
||||
//
|
||||
// @example {"sku": "LAPTOP-001", "quantity": 1}
|
||||
func (s *InventoryService) ReserveStock(ctx context.Context, req *ReserveStockRequest, rsp *ReserveStockResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.products[req.SKU]
|
||||
if !ok {
|
||||
return fmt.Errorf("product %s not found", req.SKU)
|
||||
}
|
||||
if p.InStock < req.Quantity {
|
||||
rsp.Reserved = false
|
||||
rsp.Remaining = p.InStock
|
||||
rsp.Message = fmt.Sprintf("insufficient stock: requested %d but only %d available", req.Quantity, p.InStock)
|
||||
return nil
|
||||
}
|
||||
p.InStock -= req.Quantity
|
||||
rsp.Reserved = true
|
||||
rsp.Remaining = p.InStock
|
||||
rsp.Message = fmt.Sprintf("reserved %d units of %s", req.Quantity, p.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orders service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Order struct {
|
||||
ID string `json:"id" description:"Unique order identifier"`
|
||||
Customer string `json:"customer" description:"Customer name or email"`
|
||||
SKU string `json:"sku" description:"Product SKU ordered"`
|
||||
Quantity int `json:"quantity" description:"Number of units"`
|
||||
Total float64 `json:"total" description:"Total order amount in USD"`
|
||||
Status string `json:"status" description:"Order status: pending, confirmed, shipped, delivered"`
|
||||
CreatedAt time.Time `json:"created_at" description:"When the order was placed"`
|
||||
}
|
||||
|
||||
type PlaceOrderRequest struct {
|
||||
Customer string `json:"customer" description:"Customer name or email (required)"`
|
||||
SKU string `json:"sku" description:"Product SKU to order (required)"`
|
||||
Quantity int `json:"quantity" description:"Number of units (required, must be positive)"`
|
||||
}
|
||||
|
||||
type PlaceOrderResponse struct {
|
||||
Order *Order `json:"order" description:"The newly created order"`
|
||||
}
|
||||
|
||||
type GetOrderRequest struct {
|
||||
ID string `json:"id" description:"Order ID to look up"`
|
||||
}
|
||||
|
||||
type GetOrderResponse struct {
|
||||
Order *Order `json:"order" description:"The requested order"`
|
||||
}
|
||||
|
||||
type ListOrdersRequest struct {
|
||||
Customer string `json:"customer,omitempty" description:"Filter by customer (optional)"`
|
||||
Status string `json:"status,omitempty" description:"Filter by status (optional)"`
|
||||
}
|
||||
|
||||
type ListOrdersResponse struct {
|
||||
Orders []*Order `json:"orders" description:"Matching orders"`
|
||||
}
|
||||
|
||||
type OrderService struct {
|
||||
mu sync.RWMutex
|
||||
orders map[string]*Order
|
||||
nextID int
|
||||
// In a real app this would be a client to the inventory service
|
||||
inventory *InventoryService
|
||||
}
|
||||
|
||||
// PlaceOrder creates a new order. Stock must be reserved first via
|
||||
// InventoryService.ReserveStock — this service does not check inventory.
|
||||
//
|
||||
// @example {"customer": "alice@example.com", "sku": "LAPTOP-001", "quantity": 1}
|
||||
func (s *OrderService) PlaceOrder(ctx context.Context, req *PlaceOrderRequest, rsp *PlaceOrderResponse) error {
|
||||
if req.Customer == "" {
|
||||
return fmt.Errorf("customer is required")
|
||||
}
|
||||
if req.SKU == "" {
|
||||
return fmt.Errorf("sku is required")
|
||||
}
|
||||
if req.Quantity <= 0 {
|
||||
return fmt.Errorf("quantity must be positive")
|
||||
}
|
||||
|
||||
// Look up price
|
||||
s.inventory.mu.RLock()
|
||||
p, ok := s.inventory.products[req.SKU]
|
||||
s.inventory.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("product %s not found", req.SKU)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
order := &Order{
|
||||
ID: fmt.Sprintf("ORD-%04d", s.nextID),
|
||||
Customer: req.Customer,
|
||||
SKU: req.SKU,
|
||||
Quantity: req.Quantity,
|
||||
Total: p.Price * float64(req.Quantity),
|
||||
Status: "confirmed",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
s.orders[order.ID] = order
|
||||
rsp.Order = order
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrder retrieves an order by ID.
|
||||
//
|
||||
// @example {"id": "ORD-0001"}
|
||||
func (s *OrderService) GetOrder(ctx context.Context, req *GetOrderRequest, rsp *GetOrderResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
o, ok := s.orders[req.ID]
|
||||
if !ok {
|
||||
return fmt.Errorf("order %s not found", req.ID)
|
||||
}
|
||||
rsp.Order = o
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListOrders returns orders, optionally filtered by customer or status.
|
||||
//
|
||||
// @example {"customer": "alice@example.com"}
|
||||
func (s *OrderService) ListOrders(ctx context.Context, req *ListOrdersRequest, rsp *ListOrdersResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, o := range s.orders {
|
||||
if req.Customer != "" && o.Customer != req.Customer {
|
||||
continue
|
||||
}
|
||||
if req.Status != "" && o.Status != req.Status {
|
||||
continue
|
||||
}
|
||||
rsp.Orders = append(rsp.Orders, o)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id" description:"Notification identifier"`
|
||||
Recipient string `json:"recipient" description:"Who received the notification"`
|
||||
Subject string `json:"subject" description:"Notification subject line"`
|
||||
Body string `json:"body" description:"Notification body text"`
|
||||
Channel string `json:"channel" description:"Delivery channel: email, sms, or slack"`
|
||||
SentAt time.Time `json:"sent_at" description:"When the notification was sent"`
|
||||
}
|
||||
|
||||
type SendNotificationRequest struct {
|
||||
Recipient string `json:"recipient" description:"Email address, phone number, or Slack handle"`
|
||||
Subject string `json:"subject" description:"Subject line (required)"`
|
||||
Body string `json:"body" description:"Message body (required)"`
|
||||
Channel string `json:"channel,omitempty" description:"Channel: email (default), sms, or slack"`
|
||||
}
|
||||
|
||||
type SendNotificationResponse struct {
|
||||
Notification *Notification `json:"notification" description:"The sent notification with delivery details"`
|
||||
}
|
||||
|
||||
type ListNotificationsRequest struct {
|
||||
Recipient string `json:"recipient,omitempty" description:"Filter by recipient (optional)"`
|
||||
}
|
||||
|
||||
type ListNotificationsResponse struct {
|
||||
Notifications []*Notification `json:"notifications" description:"Sent notifications"`
|
||||
}
|
||||
|
||||
type NotificationService struct {
|
||||
mu sync.RWMutex
|
||||
notifications []*Notification
|
||||
nextID int
|
||||
}
|
||||
|
||||
// Send delivers a notification to a recipient via the specified channel.
|
||||
// Use this to confirm orders, alert users, or send updates.
|
||||
// Defaults to email if no channel is specified.
|
||||
//
|
||||
// @example {"recipient": "alice@example.com", "subject": "Order Confirmed", "body": "Your order ORD-0001 has been confirmed.", "channel": "email"}
|
||||
func (s *NotificationService) Send(ctx context.Context, req *SendNotificationRequest, rsp *SendNotificationResponse) error {
|
||||
if req.Recipient == "" {
|
||||
return fmt.Errorf("recipient is required")
|
||||
}
|
||||
if req.Subject == "" {
|
||||
return fmt.Errorf("subject is required")
|
||||
}
|
||||
if req.Body == "" {
|
||||
return fmt.Errorf("body is required")
|
||||
}
|
||||
channel := req.Channel
|
||||
if channel == "" {
|
||||
channel = "email"
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextID++
|
||||
n := &Notification{
|
||||
ID: fmt.Sprintf("notif-%d", s.nextID),
|
||||
Recipient: req.Recipient,
|
||||
Subject: req.Subject,
|
||||
Body: req.Body,
|
||||
Channel: channel,
|
||||
SentAt: time.Now(),
|
||||
}
|
||||
s.notifications = append(s.notifications, n)
|
||||
rsp.Notification = n
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns sent notifications, optionally filtered by recipient.
|
||||
//
|
||||
// @example {"recipient": "alice@example.com"}
|
||||
func (s *NotificationService) List(ctx context.Context, req *ListNotificationsRequest, rsp *ListNotificationsResponse) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, n := range s.notifications {
|
||||
if req.Recipient != "" && n.Recipient != req.Recipient {
|
||||
continue
|
||||
}
|
||||
rsp.Notifications = append(rsp.Notifications, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
service := micro.New("shop",
|
||||
micro.Address(":9090"),
|
||||
mcp.WithMCP(":3001"),
|
||||
)
|
||||
service.Init()
|
||||
|
||||
inventory := &InventoryService{products: map[string]*Product{
|
||||
"LAPTOP-001": {SKU: "LAPTOP-001", Name: "ThinkPad X1 Carbon", Price: 1299.99, InStock: 15, Category: "electronics"},
|
||||
"LAPTOP-002": {SKU: "LAPTOP-002", Name: "MacBook Air M3", Price: 1099.00, InStock: 8, Category: "electronics"},
|
||||
"PHONE-001": {SKU: "PHONE-001", Name: "Pixel 8 Pro", Price: 899.00, InStock: 23, Category: "electronics"},
|
||||
"BOOK-001": {SKU: "BOOK-001", Name: "Designing Data-Intensive Applications", Price: 45.99, InStock: 50, Category: "books"},
|
||||
"BOOK-002": {SKU: "BOOK-002", Name: "The Go Programming Language", Price: 39.99, InStock: 0, Category: "books"},
|
||||
"SHIRT-001": {SKU: "SHIRT-001", Name: "Go Gopher T-Shirt", Price: 24.99, InStock: 100, Category: "clothing"},
|
||||
}}
|
||||
|
||||
orders := &OrderService{
|
||||
orders: make(map[string]*Order),
|
||||
inventory: inventory,
|
||||
}
|
||||
|
||||
notifications := &NotificationService{}
|
||||
|
||||
service.Handle(inventory)
|
||||
service.Handle(orders)
|
||||
service.Handle(notifications)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(" Shop Workflow Demo")
|
||||
fmt.Println()
|
||||
fmt.Println(" MCP Tools: http://localhost:3001/mcp/tools")
|
||||
fmt.Println()
|
||||
fmt.Println(" Try asking an agent:")
|
||||
fmt.Println()
|
||||
fmt.Println(" \"What laptops do you have in stock?\"")
|
||||
fmt.Println(" \"Order a ThinkPad for alice@example.com and send her a confirmation\"")
|
||||
fmt.Println(" \"Check if 'The Go Programming Language' is available\"")
|
||||
fmt.Println(" \"Show me all orders for alice@example.com\"")
|
||||
fmt.Println(" \"Order 3 Go Gopher t-shirts for bob@example.com, reserve the stock, and notify him\"")
|
||||
fmt.Println()
|
||||
|
||||
if err := service.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Multi-service example: run multiple services in a single binary.
|
||||
//
|
||||
// Each service gets its own server, client, store, and cache while
|
||||
// sharing the registry, broker, and transport — so they can
|
||||
// discover and call each other within the same process.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go-micro.dev/v5"
|
||||
)
|
||||
|
||||
// -- Users service --
|
||||
|
||||
type UserRequest struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type Users struct{}
|
||||
|
||||
func (u *Users) Lookup(ctx context.Context, req *UserRequest, rsp *UserResponse) error {
|
||||
log.Printf("[users] Lookup id=%s", req.Id)
|
||||
rsp.Name = "Alice"
|
||||
rsp.Email = "alice@example.com"
|
||||
return nil
|
||||
}
|
||||
|
||||
// -- Orders service --
|
||||
|
||||
type OrderRequest struct {
|
||||
UserId string `json:"user_id"`
|
||||
}
|
||||
|
||||
type OrderResponse struct {
|
||||
OrderId string `json:"order_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Orders struct{}
|
||||
|
||||
func (o *Orders) Create(ctx context.Context, req *OrderRequest, rsp *OrderResponse) error {
|
||||
log.Printf("[orders] Create for user=%s", req.UserId)
|
||||
rsp.OrderId = "ORD-001"
|
||||
rsp.Status = "created"
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Create two services — each gets isolated server, client,
|
||||
// store, and cache instances automatically.
|
||||
users := micro.New("users", micro.Address(":9001"))
|
||||
orders := micro.New("orders", micro.Address(":9002"))
|
||||
|
||||
// Register handlers
|
||||
if err := users.Handle(new(Users)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := orders.Handle(new(Orders)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Run both services together. The group handles signals
|
||||
// and stops all services when one exits.
|
||||
g := micro.NewGroup(users, orders)
|
||||
|
||||
fmt.Println("Starting users (:9001) and orders (:9002) in a single binary")
|
||||
if err := g.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -63,22 +63,22 @@ func homeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func usersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
// Return all users
|
||||
userList := make([]*User, 0, len(users))
|
||||
for _, user := range users {
|
||||
userList = append(userList, user)
|
||||
}
|
||||
|
||||
|
||||
json.NewEncoder(w).Encode(userList)
|
||||
}
|
||||
|
||||
func userHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
// Extract user ID from path
|
||||
id := r.URL.Path[len("/users/"):]
|
||||
|
||||
|
||||
user, exists := users[id]
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -87,7 +87,7 @@ func userHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v5/auth"
|
||||
"go-micro.dev/v5/client"
|
||||
"go-micro.dev/v5/registry"
|
||||
)
|
||||
|
||||
// benchServer creates a Server with N pre-populated tools.
|
||||
func benchServer(n int, opts Options) *Server {
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = log.New(log.Writer(), "", 0)
|
||||
}
|
||||
if opts.Context == nil {
|
||||
opts.Context = context.Background()
|
||||
}
|
||||
if opts.Client == nil {
|
||||
opts.Client = client.DefaultClient
|
||||
}
|
||||
if opts.Registry == nil {
|
||||
opts.Registry = registry.DefaultRegistry
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
opts: opts,
|
||||
tools: make(map[string]*Tool, n),
|
||||
limiters: make(map[string]*rateLimiter),
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
name := toolName(i)
|
||||
s.tools[name] = &Tool{
|
||||
Name: name,
|
||||
Description: "Benchmark tool " + name,
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Resource identifier",
|
||||
},
|
||||
},
|
||||
"required": []interface{}{"id"},
|
||||
},
|
||||
Service: "bench",
|
||||
Endpoint: "Handler.Method",
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func toolName(i int) string {
|
||||
return "bench.Handler.Method" + string(rune('A'+i%26))
|
||||
}
|
||||
|
||||
// --- Benchmarks ---
|
||||
|
||||
// BenchmarkListTools measures tool listing throughput.
|
||||
// This is the most common MCP operation — agents call it on every session start.
|
||||
func BenchmarkListTools(b *testing.B) {
|
||||
for _, numTools := range []int{10, 50, 100} {
|
||||
b.Run(toolCountLabel(numTools), func(b *testing.B) {
|
||||
s := benchServer(numTools, Options{})
|
||||
req := httptest.NewRequest("GET", "/mcp/tools", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
s.handleListTools(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
b.Fatalf("unexpected status %d", w.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkListToolsParallel measures concurrent tool listing.
|
||||
func BenchmarkListToolsParallel(b *testing.B) {
|
||||
s := benchServer(50, Options{})
|
||||
req := httptest.NewRequest("GET", "/mcp/tools", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
w := httptest.NewRecorder()
|
||||
s.handleListTools(w, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkToolLookup measures tool name resolution from the tools map.
|
||||
func BenchmarkToolLookup(b *testing.B) {
|
||||
for _, numTools := range []int{10, 50, 100, 500} {
|
||||
b.Run(toolCountLabel(numTools), func(b *testing.B) {
|
||||
s := benchServer(numTools, Options{})
|
||||
name := toolName(numTools / 2) // look up a tool in the middle
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.toolsMu.RLock()
|
||||
_, ok := s.tools[name]
|
||||
s.toolsMu.RUnlock()
|
||||
if !ok {
|
||||
b.Fatal("tool not found")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkAuthInspect measures auth token inspection overhead.
|
||||
func BenchmarkAuthInspect(b *testing.B) {
|
||||
ma := &mockAuth{
|
||||
accounts: map[string]*auth.Account{
|
||||
"valid-token": {
|
||||
ID: "bench-user",
|
||||
Scopes: []string{"read", "write"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
acc, err := ma.Inspect("valid-token")
|
||||
if err != nil || acc.ID != "bench-user" {
|
||||
b.Fatal("unexpected result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkScopeCheck measures scope validation overhead per tool call.
|
||||
func BenchmarkScopeCheck(b *testing.B) {
|
||||
accountScopes := []string{"users:read", "users:write", "orders:read", "admin"}
|
||||
requiredScopes := []string{"users:write"}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
hasScope(accountScopes, requiredScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkAuditRecord measures audit record creation overhead.
|
||||
func BenchmarkAuditRecord(b *testing.B) {
|
||||
var records int
|
||||
s := benchServer(10, Options{
|
||||
AuditFunc: func(r AuditRecord) {
|
||||
records++
|
||||
},
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.opts.AuditFunc(AuditRecord{
|
||||
TraceID: "trace-123",
|
||||
Tool: "bench.Handler.MethodA",
|
||||
AccountID: "user-1",
|
||||
Allowed: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRateLimiter measures rate limiter check overhead.
|
||||
func BenchmarkRateLimiter(b *testing.B) {
|
||||
s := benchServer(10, Options{
|
||||
RateLimit: &RateLimitConfig{
|
||||
RequestsPerSecond: 1000000, // Very high so it doesn't block
|
||||
Burst: 1000000,
|
||||
},
|
||||
})
|
||||
// Initialize limiters for tools
|
||||
for name := range s.tools {
|
||||
s.limiters[name] = newRateLimiter(s.opts.RateLimit.RequestsPerSecond, s.opts.RateLimit.Burst)
|
||||
}
|
||||
name := toolName(0)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.limitersMu.RLock()
|
||||
l := s.limiters[name]
|
||||
s.limitersMu.RUnlock()
|
||||
l.Allow()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJSONEncodeTool measures JSON serialization of tool definitions.
|
||||
func BenchmarkJSONEncodeTool(b *testing.B) {
|
||||
tool := &Tool{
|
||||
Name: "myservice.Users.GetUser",
|
||||
Description: "Retrieve a user by their unique ID. Returns the full profile.",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "User ID in UUID format",
|
||||
},
|
||||
},
|
||||
"required": []interface{}{"id"},
|
||||
},
|
||||
Scopes: []string{"users:read"},
|
||||
Service: "myservice",
|
||||
Endpoint: "Users.GetUser",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(tool)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJSONDecodeCallRequest measures parsing of incoming tool call requests.
|
||||
func BenchmarkJSONDecodeCallRequest(b *testing.B) {
|
||||
body := []byte(`{"tool":"myservice.Users.GetUser","arguments":{"id":"user-123"}}`)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var req struct {
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
json.Unmarshal(body, &req)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func toolCountLabel(n int) string {
|
||||
switch {
|
||||
case n >= 500:
|
||||
return "500_tools"
|
||||
case n >= 100:
|
||||
return "100_tools"
|
||||
case n >= 50:
|
||||
return "50_tools"
|
||||
default:
|
||||
return "10_tools"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CircuitBreakerConfig configures circuit breaking for the MCP gateway.
|
||||
// When a downstream service fails repeatedly, the circuit opens and
|
||||
// subsequent calls are rejected immediately until the service recovers.
|
||||
type CircuitBreakerConfig struct {
|
||||
// MaxFailures is the number of consecutive failures before the circuit opens.
|
||||
// Default: 5
|
||||
MaxFailures int
|
||||
|
||||
// Timeout is how long the circuit stays open before allowing a probe request.
|
||||
// Default: 30s
|
||||
Timeout time.Duration
|
||||
|
||||
// MaxHalfOpen is the number of probe requests allowed in the half-open state.
|
||||
// If they all succeed, the circuit closes. If any fail, it re-opens.
|
||||
// Default: 1
|
||||
MaxHalfOpen int
|
||||
}
|
||||
|
||||
// circuitState represents the state of a circuit breaker.
|
||||
type circuitState int
|
||||
|
||||
const (
|
||||
circuitClosed circuitState = iota // healthy, requests flow through
|
||||
circuitOpen // tripped, requests are rejected
|
||||
circuitHalfOpen // testing recovery with limited requests
|
||||
)
|
||||
|
||||
func (s circuitState) String() string {
|
||||
switch s {
|
||||
case circuitClosed:
|
||||
return "closed"
|
||||
case circuitOpen:
|
||||
return "open"
|
||||
case circuitHalfOpen:
|
||||
return "half-open"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// circuitBreaker tracks failure state for a single tool/service endpoint.
|
||||
type circuitBreaker struct {
|
||||
mu sync.Mutex
|
||||
state circuitState
|
||||
failures int
|
||||
maxFailures int
|
||||
timeout time.Duration
|
||||
maxHalfOpen int
|
||||
halfOpenUsed int
|
||||
lastFailure time.Time
|
||||
}
|
||||
|
||||
func newCircuitBreaker(cfg CircuitBreakerConfig) *circuitBreaker {
|
||||
maxFailures := cfg.MaxFailures
|
||||
if maxFailures <= 0 {
|
||||
maxFailures = 5
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
maxHalfOpen := cfg.MaxHalfOpen
|
||||
if maxHalfOpen <= 0 {
|
||||
maxHalfOpen = 1
|
||||
}
|
||||
return &circuitBreaker{
|
||||
state: circuitClosed,
|
||||
maxFailures: maxFailures,
|
||||
timeout: timeout,
|
||||
maxHalfOpen: maxHalfOpen,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks whether a request should be allowed through.
|
||||
// Returns nil if allowed, error if the circuit is open.
|
||||
func (cb *circuitBreaker) Allow() error {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
switch cb.state {
|
||||
case circuitClosed:
|
||||
return nil
|
||||
case circuitOpen:
|
||||
if time.Since(cb.lastFailure) > cb.timeout {
|
||||
cb.state = circuitHalfOpen
|
||||
cb.halfOpenUsed = 0
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("circuit breaker open (consecutive failures: %d)", cb.failures)
|
||||
case circuitHalfOpen:
|
||||
if cb.halfOpenUsed < cb.maxHalfOpen {
|
||||
cb.halfOpenUsed++
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("circuit breaker half-open (probe limit reached)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordSuccess records a successful call. If half-open, closes the circuit.
|
||||
func (cb *circuitBreaker) RecordSuccess() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.failures = 0
|
||||
cb.state = circuitClosed
|
||||
}
|
||||
|
||||
// RecordFailure records a failed call. May trip the circuit open.
|
||||
func (cb *circuitBreaker) RecordFailure() {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
cb.failures++
|
||||
cb.lastFailure = time.Now()
|
||||
|
||||
switch cb.state {
|
||||
case circuitClosed:
|
||||
if cb.failures >= cb.maxFailures {
|
||||
cb.state = circuitOpen
|
||||
}
|
||||
case circuitHalfOpen:
|
||||
// Probe failed, re-open
|
||||
cb.state = circuitOpen
|
||||
}
|
||||
}
|
||||
|
||||
// State returns the current circuit state.
|
||||
func (cb *circuitBreaker) State() circuitState {
|
||||
cb.mu.Lock()
|
||||
defer cb.mu.Unlock()
|
||||
|
||||
// Check for automatic transition from open -> half-open
|
||||
if cb.state == circuitOpen && time.Since(cb.lastFailure) > cb.timeout {
|
||||
cb.state = circuitHalfOpen
|
||||
cb.halfOpenUsed = 0
|
||||
}
|
||||
return cb.state
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCircuitBreaker_ClosedAllowsRequests(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{MaxFailures: 3, Timeout: time.Second})
|
||||
if err := cb.Allow(); err != nil {
|
||||
t.Fatalf("expected closed circuit to allow, got: %v", err)
|
||||
}
|
||||
if cb.State() != circuitClosed {
|
||||
t.Fatalf("expected closed state, got %s", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_OpensAfterMaxFailures(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{MaxFailures: 3, Timeout: time.Minute})
|
||||
|
||||
// 2 failures: still closed
|
||||
cb.RecordFailure()
|
||||
cb.RecordFailure()
|
||||
if cb.State() != circuitClosed {
|
||||
t.Fatalf("expected closed after 2 failures, got %s", cb.State())
|
||||
}
|
||||
|
||||
// 3rd failure: trips open
|
||||
cb.RecordFailure()
|
||||
if cb.State() != circuitOpen {
|
||||
t.Fatalf("expected open after 3 failures, got %s", cb.State())
|
||||
}
|
||||
|
||||
// Requests should be rejected
|
||||
if err := cb.Allow(); err == nil {
|
||||
t.Fatal("expected open circuit to reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{MaxFailures: 3, Timeout: time.Minute})
|
||||
|
||||
cb.RecordFailure()
|
||||
cb.RecordFailure()
|
||||
cb.RecordSuccess() // resets
|
||||
cb.RecordFailure()
|
||||
cb.RecordFailure()
|
||||
|
||||
// Should still be closed (only 2 consecutive failures)
|
||||
if cb.State() != circuitClosed {
|
||||
t.Fatalf("expected closed after reset, got %s", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenAfterTimeout(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
MaxHalfOpen: 1,
|
||||
})
|
||||
|
||||
cb.RecordFailure()
|
||||
if cb.State() != circuitOpen {
|
||||
t.Fatalf("expected open, got %s", cb.State())
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Should transition to half-open
|
||||
if cb.State() != circuitHalfOpen {
|
||||
t.Fatalf("expected half-open after timeout, got %s", cb.State())
|
||||
}
|
||||
|
||||
// One probe request should be allowed
|
||||
if err := cb.Allow(); err != nil {
|
||||
t.Fatalf("expected half-open to allow probe, got: %v", err)
|
||||
}
|
||||
|
||||
// Second should be rejected (maxHalfOpen=1, already used)
|
||||
if err := cb.Allow(); err == nil {
|
||||
t.Fatal("expected half-open to reject after max probes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenSuccessCloses(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
})
|
||||
|
||||
cb.RecordFailure()
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Allow probe
|
||||
if err := cb.Allow(); err != nil {
|
||||
t.Fatalf("expected probe allowed: %v", err)
|
||||
}
|
||||
|
||||
// Probe succeeds -> circuit closes
|
||||
cb.RecordSuccess()
|
||||
if cb.State() != circuitClosed {
|
||||
t.Fatalf("expected closed after successful probe, got %s", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_HalfOpenFailureReopens(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 1,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
})
|
||||
|
||||
cb.RecordFailure()
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Allow probe
|
||||
cb.Allow()
|
||||
|
||||
// Probe fails -> circuit re-opens
|
||||
cb.RecordFailure()
|
||||
if cb.State() != circuitOpen {
|
||||
t.Fatalf("expected open after failed probe, got %s", cb.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_Defaults(t *testing.T) {
|
||||
cb := newCircuitBreaker(CircuitBreakerConfig{})
|
||||
|
||||
if cb.maxFailures != 5 {
|
||||
t.Fatalf("expected default maxFailures=5, got %d", cb.maxFailures)
|
||||
}
|
||||
if cb.timeout != 30*time.Second {
|
||||
t.Fatalf("expected default timeout=30s, got %s", cb.timeout)
|
||||
}
|
||||
if cb.maxHalfOpen != 1 {
|
||||
t.Fatalf("expected default maxHalfOpen=1, got %d", cb.maxHalfOpen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreaker_StateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
state circuitState
|
||||
want string
|
||||
}{
|
||||
{circuitClosed, "closed"},
|
||||
{circuitOpen, "open"},
|
||||
{circuitHalfOpen, "half-open"},
|
||||
{circuitState(99), "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := tt.state.String(); got != tt.want {
|
||||
t.Errorf("state %d: got %q, want %q", tt.state, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: v2
|
||||
name: mcp-gateway
|
||||
description: Go Micro MCP Gateway - Expose microservices as AI-accessible tools via the Model Context Protocol
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
keywords:
|
||||
- go-micro
|
||||
- mcp
|
||||
- ai
|
||||
- microservices
|
||||
- gateway
|
||||
home: https://go-micro.dev
|
||||
sources:
|
||||
- https://github.com/micro/go-micro
|
||||
maintainers:
|
||||
- name: go-micro
|
||||
url: https://github.com/micro/go-micro
|
||||
@@ -0,0 +1,89 @@
|
||||
# MCP Gateway Helm Chart
|
||||
|
||||
Deploy the Go Micro MCP Gateway on Kubernetes. The gateway discovers go-micro services via a registry and exposes them as AI-accessible tools through the Model Context Protocol.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
helm install mcp-gateway ./deploy/helm/mcp-gateway \
|
||||
--set gateway.registry=consul \
|
||||
--set gateway.registryAddress=consul:8500
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `replicaCount` | Number of gateway replicas | `1` |
|
||||
| `image.repository` | Container image | `ghcr.io/micro/mcp-gateway` |
|
||||
| `image.tag` | Image tag (defaults to appVersion) | `""` |
|
||||
| `gateway.address` | Listen address | `:3000` |
|
||||
| `gateway.registry` | Registry backend (mdns, consul, etcd) | `consul` |
|
||||
| `gateway.registryAddress` | Registry address | `consul:8500` |
|
||||
| `gateway.rateLimit` | Requests/second per tool (0=unlimited) | `0` |
|
||||
| `gateway.rateBurst` | Rate limit burst size | `20` |
|
||||
| `gateway.auth` | Enable JWT authentication | `false` |
|
||||
| `gateway.audit` | Enable audit logging | `false` |
|
||||
| `gateway.scopes` | Per-tool scope requirements | `[]` |
|
||||
| `service.type` | Kubernetes service type | `ClusterIP` |
|
||||
| `service.port` | Service port | `3000` |
|
||||
| `ingress.enabled` | Enable ingress | `false` |
|
||||
| `autoscaling.enabled` | Enable HPA | `false` |
|
||||
| `autoscaling.minReplicas` | Minimum replicas | `1` |
|
||||
| `autoscaling.maxReplicas` | Maximum replicas | `10` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Production with Consul
|
||||
|
||||
```bash
|
||||
helm install mcp-gateway ./deploy/helm/mcp-gateway \
|
||||
--set replicaCount=3 \
|
||||
--set gateway.registry=consul \
|
||||
--set gateway.registryAddress=consul.default.svc:8500 \
|
||||
--set gateway.auth=true \
|
||||
--set gateway.audit=true \
|
||||
--set gateway.rateLimit=100 \
|
||||
--set autoscaling.enabled=true
|
||||
```
|
||||
|
||||
### With Ingress (nginx)
|
||||
|
||||
```bash
|
||||
helm install mcp-gateway ./deploy/helm/mcp-gateway \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.className=nginx \
|
||||
--set ingress.hosts[0].host=mcp.example.com \
|
||||
--set ingress.hosts[0].paths[0].path=/ \
|
||||
--set ingress.hosts[0].paths[0].pathType=Prefix \
|
||||
--set ingress.tls[0].secretName=mcp-tls \
|
||||
--set ingress.tls[0].hosts[0]=mcp.example.com
|
||||
```
|
||||
|
||||
### With Scopes
|
||||
|
||||
```bash
|
||||
helm install mcp-gateway ./deploy/helm/mcp-gateway \
|
||||
--set gateway.auth=true \
|
||||
--set 'gateway.scopes[0]=blog.Blog.Create=blog:write' \
|
||||
--set 'gateway.scopes[1]=blog.Blog.Delete=blog:admin'
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Kubernetes Cluster
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────┐ MCP ┌─────────────┐ RPC ┌──────┐ │
|
||||
│ │ Ingress │ ───────> │ MCP Gateway │ ──────> │ Svc │ │
|
||||
│ │ │ │ (N pods) │ │ Pods │ │
|
||||
│ └─────────┘ └─────────────┘ └──────┘ │
|
||||
│ │ │ │
|
||||
│ v v │
|
||||
│ ┌──────────┐ │
|
||||
│ │ Consul │ │
|
||||
│ │ Registry │ │
|
||||
│ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
MCP Gateway has been deployed.
|
||||
|
||||
1. Get the gateway URL:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mcp-gateway.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mcp-gateway.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else }}
|
||||
kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ include "mcp-gateway.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
|
||||
echo http://127.0.0.1:{{ .Values.service.port }}
|
||||
{{- end }}
|
||||
|
||||
2. List available MCP tools:
|
||||
curl http://<GATEWAY_URL>/mcp/tools | jq
|
||||
|
||||
3. Connect Claude Code:
|
||||
Add to your MCP settings:
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-services": {
|
||||
"url": "http://<GATEWAY_URL>/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "mcp-gateway.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "mcp-gateway.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "mcp-gateway.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "mcp-gateway.labels" -}}
|
||||
helm.sh/chart: {{ include "mcp-gateway.chart" . }}
|
||||
{{ include "mcp-gateway.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "mcp-gateway.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "mcp-gateway.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "mcp-gateway.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "mcp-gateway.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,95 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "mcp-gateway.fullname" . }}
|
||||
labels:
|
||||
{{- include "mcp-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "mcp-gateway.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "mcp-gateway.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "mcp-gateway.serviceAccountName" . }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
{{- with .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args:
|
||||
- "--address"
|
||||
- {{ .Values.gateway.address | quote }}
|
||||
- "--registry"
|
||||
- {{ .Values.gateway.registry | quote }}
|
||||
{{- if .Values.gateway.registryAddress }}
|
||||
- "--registry-address"
|
||||
- {{ .Values.gateway.registryAddress | quote }}
|
||||
{{- end }}
|
||||
{{- if gt (float64 .Values.gateway.rateLimit) 0.0 }}
|
||||
- "--rate-limit"
|
||||
- {{ .Values.gateway.rateLimit | quote }}
|
||||
- "--rate-burst"
|
||||
- {{ .Values.gateway.rateBurst | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.gateway.auth }}
|
||||
- "--auth"
|
||||
{{- end }}
|
||||
{{- if .Values.gateway.audit }}
|
||||
- "--audit"
|
||||
{{- end }}
|
||||
{{- range .Values.gateway.scopes }}
|
||||
- "--scope"
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ trimPrefix ":" .Values.gateway.address | default "3000" }}
|
||||
protocol: TCP
|
||||
{{- with .Values.probes.liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.probes.readiness }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "mcp-gateway.fullname" . }}
|
||||
labels:
|
||||
{{- include "mcp-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "mcp-gateway.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "mcp-gateway.fullname" . }}
|
||||
labels:
|
||||
{{- include "mcp-gateway.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "mcp-gateway.fullname" $ }}
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "mcp-gateway.fullname" . }}
|
||||
labels:
|
||||
{{- include "mcp-gateway.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "mcp-gateway.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "mcp-gateway.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "mcp-gateway.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,112 @@
|
||||
# MCP Gateway Helm chart values
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/micro/mcp-gateway
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "" # Defaults to appVersion
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# MCP Gateway configuration
|
||||
gateway:
|
||||
# Listen address (port inside the container)
|
||||
address: ":3000"
|
||||
|
||||
# Service registry backend: mdns, consul, etcd
|
||||
registry: consul
|
||||
|
||||
# Registry address (e.g., consul:8500, etcd:2379)
|
||||
registryAddress: "consul:8500"
|
||||
|
||||
# Rate limiting (0 = unlimited)
|
||||
rateLimit: 0
|
||||
rateBurst: 20
|
||||
|
||||
# Enable JWT authentication
|
||||
auth: false
|
||||
|
||||
# Enable audit logging to stdout
|
||||
audit: false
|
||||
|
||||
# Per-tool scope requirements (format: tool=scope1,scope2)
|
||||
scopes: []
|
||||
# - "blog.Blog.Create=blog:write"
|
||||
# - "blog.Blog.Delete=blog:admin"
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
automount: true
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext: {}
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
# kubernetes.io/ingress.class: nginx
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: mcp-gateway.local
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: mcp-gateway-tls
|
||||
# hosts:
|
||||
# - mcp-gateway.local
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 128Mi
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
targetMemoryUtilizationPercentage: 80
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
# Liveness and readiness probes
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user