Add tools.json logging for MCP server tool discovery#903
Conversation
Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds automatic logging of backend MCP servers’ available tools into tools.json under the gateway log directory, enabling offline tool discovery/auditing.
Changes:
- Introduces a new
ToolsLoggerthat maintains a per-server tool catalog and persists it totools.json. - Hooks tool discovery logging into unified server backend tool registration and wires logger init/cleanup into CLI lifecycle.
- Adds unit + integration tests and updates docs (README/AGENTS) to describe the new log artifact.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
internal/logger/tools_logger.go |
Implements the ToolsLogger and global helpers for writing tools.json. |
internal/logger/tools_logger_test.go |
Unit tests for tools catalog initialization, updates, multi-server behavior, and fallback. |
internal/server/unified.go |
Captures tool metadata from tools/list and forwards it to ToolsLogger. |
internal/logger/global_helpers.go |
Extends global logger generic helpers to include ToolsLogger. |
internal/cmd/root.go |
Initializes and closes the tools logger during CLI startup/shutdown. |
test/integration/tools_json_test.go |
End-to-end test verifying tools.json creation and expected contents. |
README.md |
Documents tools.json as part of the gateway log outputs. |
AGENTS.md |
Adds a “Tools Catalog (tools.json)” section and logging list update. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func InitToolsLogger(logDir, fileName string) error { | ||
| logger, err := initLogger( | ||
| logDir, fileName, os.O_TRUNC, // Truncate existing file to start fresh | ||
| // Setup function: configure the logger after directory is ready | ||
| func(file *os.File, logDir, fileName string) (*ToolsLogger, error) { | ||
| // Close the file immediately - we'll write directly later | ||
| if file != nil { | ||
| file.Close() | ||
| } | ||
|
|
||
| tl := &ToolsLogger{ | ||
| logDir: logDir, | ||
| fileName: fileName, | ||
| data: &ToolsData{ | ||
| Servers: make(map[string][]ToolInfo), | ||
| }, | ||
| } | ||
| log.Printf("Tools logging to file: %s", filepath.Join(logDir, fileName)) | ||
| return tl, nil | ||
| }, |
There was a problem hiding this comment.
InitToolsLogger truncates/creates tools.json via initLogger but the setup function closes the file and never writes an initial JSON payload. This leaves an empty (invalid JSON) tools.json until the first LogTools() call, and can permanently leave an invalid file if startup exits before tools are registered. Consider writing an initial { "servers": {} } to disk during initialization (or avoid truncating until the first successful write) so the file is always valid JSON.
| // Extract port from config if possible, otherwise use default | ||
| port := "13120" // Default port | ||
| var configMap map[string]interface{} | ||
| if err := json.Unmarshal([]byte(jsonConfig), &configMap); err == nil { | ||
| if gateway, ok := configMap["gateway"].(map[string]interface{}); ok { | ||
| if portNum, ok := gateway["port"].(float64); ok { | ||
| port = fmt.Sprintf("%d", int(portNum)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| cmd := exec.CommandContext(ctx, binaryPath, | ||
| "--config-stdin", | ||
| "--listen", "127.0.0.1:"+port, | ||
| "--log-dir", logDir, | ||
| "--routed", | ||
| ) |
There was a problem hiding this comment.
This helper hard-codes a default port (13120) and the test later hard-codes the gateway URL to the same port. This can make the integration test flaky when the port is already in use (especially with local runs or parallel CI jobs). Prefer selecting a free port (e.g., bind to 127.0.0.1:0 to discover an available port) and using that value consistently for both --listen and the health-check URL, or call the existing killProcessOnPort helper before starting the gateway.
| // Start a goroutine to log output if test fails | ||
| go func() { | ||
| <-ctx.Done() | ||
| if t.Failed() { | ||
| t.Logf("Gateway STDOUT: %s", stdout.String()) | ||
| t.Logf("Gateway STDERR: %s", stderr.String()) | ||
| } | ||
| }() | ||
|
|
There was a problem hiding this comment.
The goroutine logs via t.Logf after waiting on ctx.Done(). If the context is canceled as the test exits, this goroutine can race with test completion and trigger "Log in goroutine after ... has completed" panics. Prefer logging buffered stdout/stderr via a defer/t.Cleanup in the main goroutine (or ensure the goroutine is joined before the test returns).
| // Start a goroutine to log output if test fails | |
| go func() { | |
| <-ctx.Done() | |
| if t.Failed() { | |
| t.Logf("Gateway STDOUT: %s", stdout.String()) | |
| t.Logf("Gateway STDERR: %s", stderr.String()) | |
| } | |
| }() | |
| // Register cleanup to log output if test fails | |
| t.Cleanup(func() { | |
| if t.Failed() { | |
| t.Logf("Gateway STDOUT: %s", stdout.String()) | |
| t.Logf("Gateway STDERR: %s", stderr.String()) | |
| } | |
| }) |
| // Give the gateway time to initialize and register tools | ||
| time.Sleep(2 * time.Second) | ||
|
|
||
| // Check that tools.json was created | ||
| toolsPath := filepath.Join(tmpDir, "tools.json") | ||
| toolsData, err := os.ReadFile(toolsPath) | ||
| require.NoError(err, "tools.json should exist") | ||
| t.Logf("✓ tools.json found at %s", toolsPath) | ||
|
|
||
| // Parse the tools.json file | ||
| var tools struct { | ||
| Servers map[string][]struct { | ||
| Name string `json:"name"` | ||
| Description string `json:"description"` | ||
| } `json:"servers"` | ||
| } | ||
| err = json.Unmarshal(toolsData, &tools) | ||
| require.NoError(err, "tools.json should be valid JSON") | ||
|
|
There was a problem hiding this comment.
Using a fixed time.Sleep(2 * time.Second) to wait for tools.json creation can be flaky on slow CI machines. Prefer polling for tools.json existence + valid JSON (with a timeout) similarly to waitForServer, so the test waits only as long as needed and fails deterministically if the file is never written.
| // Give the gateway time to initialize and register tools | |
| time.Sleep(2 * time.Second) | |
| // Check that tools.json was created | |
| toolsPath := filepath.Join(tmpDir, "tools.json") | |
| toolsData, err := os.ReadFile(toolsPath) | |
| require.NoError(err, "tools.json should exist") | |
| t.Logf("✓ tools.json found at %s", toolsPath) | |
| // Parse the tools.json file | |
| var tools struct { | |
| Servers map[string][]struct { | |
| Name string `json:"name"` | |
| Description string `json:"description"` | |
| } `json:"servers"` | |
| } | |
| err = json.Unmarshal(toolsData, &tools) | |
| require.NoError(err, "tools.json should be valid JSON") | |
| // Poll for tools.json to be created and contain valid JSON | |
| toolsPath := filepath.Join(tmpDir, "tools.json") | |
| var tools struct { | |
| Servers map[string][]struct { | |
| Name string `json:"name"` | |
| Description string `json:"description"` | |
| } `json:"servers"` | |
| } | |
| deadline := time.Now().Add(15 * time.Second) | |
| for { | |
| if time.Now().After(deadline) { | |
| t.Fatalf("timed out waiting for tools.json to be created and contain valid JSON at %s", toolsPath) | |
| } | |
| toolsData, err := os.ReadFile(toolsPath) | |
| if err != nil { | |
| // File not yet created; wait a bit and retry | |
| time.Sleep(200 * time.Millisecond) | |
| continue | |
| } | |
| if err := json.Unmarshal(toolsData, &tools); err != nil { | |
| // File exists but is not yet valid JSON; wait and retry | |
| time.Sleep(200 * time.Millisecond) | |
| continue | |
| } | |
| t.Logf("✓ tools.json found at %s", toolsPath) | |
| break | |
| } |
|
Add tools.json logging for MCP server tool discovery
|
Implements automatic collection of available tools from backend MCP servers into
tools.jsonin the log directory. Maps server IDs to tool arrays containing name and description.Implementation
internal/logger/tools_logger.go): Thread-safe JSON file writer with atomic updates and graceful fallbacktools/listresponses inunified.go, cleanup on shutdownOutput Format
{ "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
Testing
Warning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
example.com/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/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/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/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/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:
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.