Skip to content

Commit a5d11f2

Browse files
authored
Add tools.json logging for MCP server tool discovery (#903)
Implements automatic collection of available tools from backend MCP servers into `tools.json` in the log directory. Maps server IDs to tool arrays containing name and description. ## Implementation - **ToolsLogger** (`internal/logger/tools_logger.go`): Thread-safe JSON file writer with atomic updates and graceful fallback - **Gateway integration**: Initialize on startup, capture tools from `tools/list` responses in `unified.go`, cleanup on shutdown - **Data structure**: Original tool names (pre-prefix) with descriptions per server ## Output Format ```json { "servers": { "github": [ {"name": "search_code", "description": "Fast code search across repositories"}, {"name": "get_file_contents", "description": "Get file contents from repository"} ], "slack": [ {"name": "send_message", "description": "Send message to channel"} ] } } ``` ## Use Cases - Tool discovery without RPC calls - Capability auditing and monitoring - Client configuration and documentation ## Testing - Unit tests: initialization, multi-server, updates, edge cases - Integration test: end-to-end with mock MCP servers > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `example.com` > - Triggering command: `/tmp/go-build801673111/b275/launcher.test /tmp/go-build801673111/b275/launcher.test -test.testlogfile=/tmp/go-build801673111/b275/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a --global .12/x64/as credential.helpe/opt/hostedtoolcache/go/1.25.6/x64/pkg/tool/linux_amd64/compile go` (dns block) > - `invalid-host-that-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build801673111/b260/config.test /tmp/go-build801673111/b260/config.test -test.testlogfile=/tmp/go-build801673111/b260/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true -c=4 -nolocalimports -importcfg /tmp/go-build4280343458/b076/importcfg -pack /home/REDACTED/work/gh-aw-mcpg/gh-aw-mcpg/internal/logger/common.go /home/REDACTED/work/gh-aw-mcpg/gh-aw-mcpg/internal/logger/constants.go conf�� go TqgVVKUfb ache/Python/3.12.12/x64/bin/bash--64 user.name` (dns block) > - `nonexistent.local` > - Triggering command: `/tmp/go-build801673111/b275/launcher.test /tmp/go-build801673111/b275/launcher.test -test.testlogfile=/tmp/go-build801673111/b275/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a --global .12/x64/as credential.helpe/opt/hostedtoolcache/go/1.25.6/x64/pkg/tool/linux_amd64/compile go` (dns block) > - `slow.example.com` > - Triggering command: `/tmp/go-build801673111/b275/launcher.test /tmp/go-build801673111/b275/launcher.test -test.testlogfile=/tmp/go-build801673111/b275/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a --global .12/x64/as credential.helpe/opt/hostedtoolcache/go/1.25.6/x64/pkg/tool/linux_amd64/compile go` (dns block) > - `this-host-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build801673111/b284/mcp.test /tmp/go-build801673111/b284/mcp.test -test.testlogfile=/tmp/go-build801673111/b284/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/c-p 64/src/crypto/crgithub.com/github/gh-aw-mcpg/internal/difc ache/go/1.25.6/x-lang=go1.25 user.name` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent) (admins only) > > </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey).
2 parents da1ad16 + 197e8b7 commit a5d11f2

8 files changed

Lines changed: 717 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ DEBUG_COLORS=0 DEBUG=* ./awmg --config config.toml
382382
- `{serverID}.log` - Per-server logs (e.g., `github.log`, `slack.log`) for easier troubleshooting
383383
- `gateway.md` - Markdown-formatted logs for GitHub workflow previews
384384
- `rpc-messages.jsonl` - Machine-readable JSONL format for RPC message analysis
385+
- `tools.json` - Available tools from all backend MCP servers (mapping server IDs to their tool names and descriptions)
385386
- Logs include: startup, client interactions, backend operations, auth events, errors
386387

387388
**Per-ServerID Logging:**
@@ -406,6 +407,29 @@ DEBUG_COLORS=0 DEBUG=* ./awmg --config config.toml
406407
- The `payloadPreview` shows the first 500 characters of the JSON for quick reference
407408
- To access the full data with all actual values, read the JSON file at `payloadPath`
408409

410+
**Tools Catalog (tools.json):**
411+
- The gateway maintains a catalog of all available tools from backend MCP servers in `tools.json`
412+
- Located in the log directory (e.g., `/tmp/gh-aw/mcp-logs/tools.json`)
413+
- Updated automatically during gateway startup when backend servers are registered
414+
- Format: JSON mapping of server IDs to arrays of tool information
415+
- Each tool includes: `name` (tool name without server prefix) and `description`
416+
- Example structure:
417+
```json
418+
{
419+
"servers": {
420+
"github": [
421+
{"name": "search_code", "description": "Search for code in repositories"},
422+
{"name": "get_file_contents", "description": "Get the contents of a file"}
423+
],
424+
"slack": [
425+
{"name": "send_message", "description": "Send a message to a Slack channel"}
426+
]
427+
}
428+
}
429+
```
430+
- Useful for discovering available tools across all configured backend servers
431+
- Can be used by clients or monitoring tools to understand gateway capabilities
432+
409433
## Error Debugging
410434

411435
**Enhanced Error Context**: Command failures include:

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ For detailed setup instructions, building from source, and local development, se
7272
- `{serverID}.log`: Per-server logs for easier troubleshooting
7373
- `gateway.md`: Markdown-formatted logs for GitHub workflow previews
7474
- `rpc-messages.jsonl`: Machine-readable RPC message logs
75+
- `tools.json`: Available tools from all backend MCP servers
7576
- `-p 8000:8000`: Port mapping must match `MCP_GATEWAY_PORT`
7677

7778
MCPG will start in routed mode on `http://0.0.0.0:8000` (using `MCP_GATEWAY_PORT`), proxying MCP requests to your configured backend servers.
@@ -400,6 +401,7 @@ The gateway creates multiple log files for different purposes:
400401
2. **`{serverID}.log`** - Per-server logs (e.g., `github.log`, `slack.log`) for easier troubleshooting of specific backend servers
401402
3. **`gateway.md`** - Markdown-formatted logs for GitHub workflow previews
402403
4. **`rpc-messages.jsonl`** - Machine-readable JSONL format for RPC message analysis
404+
5. **`tools.json`** - Available tools from all backend MCP servers (mapping server IDs to their tool names and descriptions)
403405

404406
### Log File Location
405407

@@ -427,7 +429,8 @@ Example log directory structure:
427429
├── slack.log # Only Slack server logs
428430
├── notion.log # Only Notion server logs
429431
├── gateway.md # Markdown format
430-
└── rpc-messages.jsonl # RPC messages
432+
├── rpc-messages.jsonl # RPC messages
433+
└── tools.json # Available tools
431434
```
432435

433436
**Using the environment variable:**

internal/cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ func postRun(cmd *cobra.Command, args []string) {
143143
logger.CloseMarkdownLogger()
144144
logger.CloseJSONLLogger()
145145
logger.CloseServerFileLogger()
146+
logger.CloseToolsLogger()
146147
logger.CloseGlobalLogger()
147148
}
148149

@@ -171,6 +172,11 @@ func run(cmd *cobra.Command, args []string) error {
171172
log.Printf("Warning: Failed to initialize JSONL logger: %v", err)
172173
}
173174

175+
// Initialize tools logger for tracking available tools
176+
if err := logger.InitToolsLogger(logDir, "tools.json"); err != nil {
177+
log.Printf("Warning: Failed to initialize tools logger: %v", err)
178+
}
179+
174180
logger.LogInfoMd("startup", "MCPG Gateway version: %s", cliVersion)
175181

176182
// Log config source based on what was provided

internal/logger/global_helpers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ package logger
1717
import "sync"
1818

1919
// closableLogger is a constraint for types that have a Close method.
20-
// This is satisfied by *FileLogger, *JSONLLogger, *MarkdownLogger, and *ServerFileLogger.
20+
// This is satisfied by *FileLogger, *JSONLLogger, *MarkdownLogger, *ServerFileLogger, and *ToolsLogger.
2121
type closableLogger interface {
22-
*FileLogger | *JSONLLogger | *MarkdownLogger | *ServerFileLogger
22+
*FileLogger | *JSONLLogger | *MarkdownLogger | *ServerFileLogger | *ToolsLogger
2323
Close() error
2424
}
2525

internal/logger/tools_logger.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Package logger provides structured logging for the MCP Gateway.
2+
//
3+
// This file implements logging of MCP server tools to a JSON file (tools.json).
4+
// It maintains a mapping of server IDs to their available tools with names and descriptions.
5+
package logger
6+
7+
import (
8+
"encoding/json"
9+
"fmt"
10+
"log"
11+
"os"
12+
"path/filepath"
13+
"sync"
14+
)
15+
16+
// ToolInfo represents information about a single tool
17+
type ToolInfo struct {
18+
Name string `json:"name"`
19+
Description string `json:"description"`
20+
}
21+
22+
// ToolsData represents the structure of tools.json
23+
type ToolsData struct {
24+
// Map of serverID to array of tools
25+
Servers map[string][]ToolInfo `json:"servers"`
26+
}
27+
28+
// ToolsLogger manages logging of MCP server tools to a JSON file
29+
type ToolsLogger struct {
30+
logDir string
31+
fileName string
32+
data *ToolsData
33+
mu sync.Mutex
34+
useFallback bool
35+
}
36+
37+
var (
38+
globalToolsLogger *ToolsLogger
39+
globalToolsMu sync.RWMutex
40+
)
41+
42+
// InitToolsLogger initializes the global tools logger
43+
// If the log directory doesn't exist and can't be created, falls back to no-op
44+
func InitToolsLogger(logDir, fileName string) error {
45+
logger, err := initLogger(
46+
logDir, fileName, os.O_TRUNC, // Truncate existing file to start fresh
47+
// Setup function: configure the logger after directory is ready
48+
func(file *os.File, logDir, fileName string) (*ToolsLogger, error) {
49+
// Close the file immediately - we'll write directly later
50+
if file != nil {
51+
file.Close()
52+
}
53+
54+
tl := &ToolsLogger{
55+
logDir: logDir,
56+
fileName: fileName,
57+
data: &ToolsData{
58+
Servers: make(map[string][]ToolInfo),
59+
},
60+
}
61+
log.Printf("Tools logging to file: %s", filepath.Join(logDir, fileName))
62+
return tl, nil
63+
},
64+
// Error handler: fallback to no-op on error
65+
func(err error, logDir, fileName string) (*ToolsLogger, error) {
66+
log.Printf("WARNING: Failed to initialize tools log file: %v", err)
67+
log.Printf("WARNING: Tools logging disabled")
68+
tl := &ToolsLogger{
69+
logDir: logDir,
70+
fileName: fileName,
71+
useFallback: true,
72+
data: &ToolsData{
73+
Servers: make(map[string][]ToolInfo),
74+
},
75+
}
76+
return tl, nil
77+
},
78+
)
79+
80+
initGlobalToolsLogger(logger)
81+
return err
82+
}
83+
84+
// LogTools logs the tools for a specific server
85+
func (tl *ToolsLogger) LogTools(serverID string, tools []ToolInfo) error {
86+
tl.mu.Lock()
87+
defer tl.mu.Unlock()
88+
89+
if tl.useFallback {
90+
return nil // Silently skip if in fallback mode
91+
}
92+
93+
// Update the data structure
94+
tl.data.Servers[serverID] = tools
95+
96+
// Write the updated data to file
97+
return tl.writeToFile()
98+
}
99+
100+
// writeToFile writes the current tools data to the JSON file
101+
// Caller must hold tl.mu lock
102+
func (tl *ToolsLogger) writeToFile() error {
103+
filePath := filepath.Join(tl.logDir, tl.fileName)
104+
105+
// Marshal to JSON with indentation for readability
106+
jsonData, err := json.MarshalIndent(tl.data, "", " ")
107+
if err != nil {
108+
return fmt.Errorf("failed to marshal tools data: %w", err)
109+
}
110+
111+
// Write to file atomically using a temp file + rename
112+
tempPath := filePath + ".tmp"
113+
if err := os.WriteFile(tempPath, jsonData, 0644); err != nil {
114+
return fmt.Errorf("failed to write temp file: %w", err)
115+
}
116+
117+
if err := os.Rename(tempPath, filePath); err != nil {
118+
// Clean up temp file on error
119+
os.Remove(tempPath)
120+
return fmt.Errorf("failed to rename temp file: %w", err)
121+
}
122+
123+
return nil
124+
}
125+
126+
// Close is a no-op for ToolsLogger (implements closableLogger interface)
127+
func (tl *ToolsLogger) Close() error {
128+
// No file handle to close since we write directly each time
129+
return nil
130+
}
131+
132+
// Global logging function that uses the global tools logger
133+
134+
// LogToolsForServer logs the tools for a specific server
135+
func LogToolsForServer(serverID string, tools []ToolInfo) {
136+
globalToolsMu.RLock()
137+
defer globalToolsMu.RUnlock()
138+
139+
if globalToolsLogger != nil {
140+
if err := globalToolsLogger.LogTools(serverID, tools); err != nil {
141+
// Log errors using the standard logger to avoid recursion
142+
log.Printf("WARNING: Failed to log tools for server %s: %v", serverID, err)
143+
}
144+
}
145+
}
146+
147+
// CloseToolsLogger closes the global tools logger
148+
func CloseToolsLogger() error {
149+
return closeGlobalToolsLogger()
150+
}
151+
152+
// initGlobalToolsLogger initializes the global ToolsLogger using the generic helper.
153+
func initGlobalToolsLogger(logger *ToolsLogger) {
154+
initGlobalLogger(&globalToolsMu, &globalToolsLogger, logger)
155+
}
156+
157+
// closeGlobalToolsLogger closes the global ToolsLogger using the generic helper.
158+
func closeGlobalToolsLogger() error {
159+
return closeGlobalLogger(&globalToolsMu, &globalToolsLogger)
160+
}

0 commit comments

Comments
 (0)