-
Notifications
You must be signed in to change notification settings - Fork 38
feat: MCP phase 1 read-only operations #579
base: main
Are you sure you want to change the base?
Changes from 1 commit
7e8f02a
4cfc3ba
eec5d99
ef42717
1f2d0e5
dce5e27
3776f57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,198 @@ | ||||||||||||||
| /* | ||||||||||||||
| * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||||||||||||||
| * SPDX-License-Identifier: Apache-2.0 | ||||||||||||||
| * | ||||||||||||||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||||||||||||||
| * you may not use this file except in compliance with the License. | ||||||||||||||
| * You may obtain a copy of the License at | ||||||||||||||
| * | ||||||||||||||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||
| * | ||||||||||||||
| * Unless required by applicable law or agreed to in writing, software | ||||||||||||||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||||||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||
| * See the License for the specific language governing permissions and | ||||||||||||||
| * limitations under the License. | ||||||||||||||
| */ | ||||||||||||||
|
|
||||||||||||||
| package mcp | ||||||||||||||
|
|
||||||||||||||
| import ( | ||||||||||||||
| "context" | ||||||||||||||
| "errors" | ||||||||||||||
| "fmt" | ||||||||||||||
| "net/http" | ||||||||||||||
| "os" | ||||||||||||||
| "os/signal" | ||||||||||||||
| "syscall" | ||||||||||||||
| "time" | ||||||||||||||
|
|
||||||||||||||
| "github.com/sirupsen/logrus" | ||||||||||||||
| urfave "github.com/urfave/cli/v2" | ||||||||||||||
|
|
||||||||||||||
| appcli "github.com/NVIDIA/infra-controller-rest/cli/pkg" | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| // Command returns the "mcp" urfave/cli command tree for nicocli. Wire | ||||||||||||||
| // it into the binary's command list from cmd/cli/main.go alongside the | ||||||||||||||
| // dynamically-generated commands the rest of the CLI ships with. | ||||||||||||||
| // | ||||||||||||||
| // specData is the OpenAPI YAML the rest of the CLI is built from; the | ||||||||||||||
| // command's "serve" action passes it to BuildServer so the MCP tool | ||||||||||||||
| // catalogue stays in lockstep with every nicocli build. | ||||||||||||||
| func Command(specData []byte) *urfave.Command { | ||||||||||||||
| return &urfave.Command{ | ||||||||||||||
| Name: "mcp", | ||||||||||||||
| Usage: "Run an MCP server that exposes the NICo REST read surface over streamable-HTTP", | ||||||||||||||
| Subcommands: []*urfave.Command{ | ||||||||||||||
| serveCommand(specData), | ||||||||||||||
| }, | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| func serveCommand(specData []byte) *urfave.Command { | ||||||||||||||
| return &urfave.Command{ | ||||||||||||||
| Name: "serve", | ||||||||||||||
| Usage: "Start the streamable-HTTP MCP server", | ||||||||||||||
| Description: "Serves the NICo REST read surface as MCP tools at /mcp on the\n" + | ||||||||||||||
| "configured listen address. The server is stateless and never emits\n" + | ||||||||||||||
| "text/event-stream responses; every tool/call returns a single JSON\n" + | ||||||||||||||
| "body. In production, place an MCP-aware gateway in front and rely on\n" + | ||||||||||||||
| "the inbound Authorization header for per-call authentication.", | ||||||||||||||
| Flags: []urfave.Flag{ | ||||||||||||||
| &urfave.StringFlag{ | ||||||||||||||
| Name: "listen", | ||||||||||||||
| Usage: "address:port to listen on", | ||||||||||||||
| EnvVars: []string{"NICO_MCP_LISTEN"}, | ||||||||||||||
| Value: ":8080", | ||||||||||||||
| }, | ||||||||||||||
| &urfave.StringFlag{ | ||||||||||||||
| Name: "path", | ||||||||||||||
| Usage: "HTTP path prefix the MCP handler is mounted at", | ||||||||||||||
| EnvVars: []string{"NICO_MCP_PATH"}, | ||||||||||||||
| Value: "/mcp", | ||||||||||||||
| }, | ||||||||||||||
| &urfave.DurationFlag{ | ||||||||||||||
| Name: "shutdown-timeout", | ||||||||||||||
| Usage: "graceful shutdown timeout when SIGINT/SIGTERM arrives", | ||||||||||||||
| EnvVars: []string{"NICO_MCP_SHUTDOWN_TIMEOUT"}, | ||||||||||||||
| Value: 10 * time.Second, | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
| Action: func(c *urfave.Context) error { | ||||||||||||||
| return runServe(c, specData) | ||||||||||||||
| }, | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // runServe wires the urfave context into Options, builds the MCP server, | ||||||||||||||
| // and runs an http.Server until SIGINT/SIGTERM. It is split out from the | ||||||||||||||
| // urfave Action closure so tests can drive it directly. | ||||||||||||||
| func runServe(c *urfave.Context, specData []byte) error { | ||||||||||||||
| opts, err := buildServeOptions(c) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return err | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| server, err := BuildServer(specData, opts) | ||||||||||||||
| if err != nil { | ||||||||||||||
| return fmt.Errorf("building MCP server: %w", err) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| listen := c.String("listen") | ||||||||||||||
| path := c.String("path") | ||||||||||||||
| shutdownTimeout := c.Duration("shutdown-timeout") | ||||||||||||||
|
|
||||||||||||||
| mux := http.NewServeMux() | ||||||||||||||
| mux.Handle(path, NewHandler(server)) | ||||||||||||||
|
|
||||||||||||||
| httpServer := &http.Server{ | ||||||||||||||
| Addr: listen, | ||||||||||||||
| Handler: mux, | ||||||||||||||
| ReadHeaderTimeout: 10 * time.Second, | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| opts.Log.Infof("nico-mcp: listening on %s, MCP at %s (stateless, JSONResponse)", listen, path) | ||||||||||||||
|
|
||||||||||||||
| errCh := make(chan error, 1) | ||||||||||||||
| go func() { | ||||||||||||||
| errCh <- httpServer.ListenAndServe() | ||||||||||||||
| }() | ||||||||||||||
|
|
||||||||||||||
| sigCh := make(chan os.Signal, 1) | ||||||||||||||
| signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | ||||||||||||||
|
|
||||||||||||||
|
Comment on lines
+122
to
+125
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate and display the relevant section in cli/mcp/cmd.go
FILE="cli/mcp/cmd.go"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE"
exit 1
fi
echo "== Context around requested lines =="
nl -ba "$FILE" | sed -n '90,170p'Repository: NVIDIA/infra-controller-rest Length of output: 171 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Search for other signal handlers / cleanup patterns across the codebase
rg -n --hidden --no-ignore-vcs "signal\.Notify\(|signal\.Stop\(|signal\.Reset\(" cli -S || trueRepository: NVIDIA/infra-controller-rest Length of output: 146 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Check for any tests that might repeatedly invoke this code path
rg -n --hidden --no-ignore-vcs "mcp.*cmd|cli/mcp|New.*MCP|cmd\.go" . -S --glob '*_test.go' || trueRepository: NVIDIA/infra-controller-rest Length of output: 54 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="cli/mcp/cmd.go"
# Show around the target region (avoid nl)
sed -n '100,150p' "$FILE" | cat -nRepository: NVIDIA/infra-controller-rest Length of output: 1846 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find all signal.Notify / signal.Stop usage in this repo
rg -n --hidden --no-ignore-vcs "signal\.Notify\(|signal\.Stop\(" . -S || trueRepository: NVIDIA/infra-controller-rest Length of output: 671 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the whole function / block likely containing the snippet
# (heuristic: print from the nearest preceding 'func' to ~30 lines after the snippet region)
FILE="cli/mcp/cmd.go"
start=$(awk 'NR>=1 && /func / {last=$1} NR>=80 && NR<=150 { } END {print last}' < "$FILE")
echo "Heuristic start NR: $start"
sed -n "${start},${start+120}p" "$FILE" | cat -nRepository: NVIDIA/infra-controller-rest Length of output: 167 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Check whether the repo uses signal.Stop anywhere
rg -n --hidden --no-ignore-vcs "signal\.Stop\(" . || true
# 2) Inspect a few other call sites around their signal.Notify to learn prevailing patterns
for f in \
"site-agent/cmd/site-agent/main.go" \
"site-agent/pkg/components/managers/context.go" \
"powershelf-manager/cmd/serve.go" \
"nvswitch-manager/cmd/serve.go" \
"flow/cmd/serve.go" \
"cert-manager/pkg/core/context.go"; do
echo "---- $f ----"
# Print ~60 lines around the first signal.Notify occurrence in each file
ln=$(rg -n "signal\.Notify\(" "$f" | head -n1 | cut -d: -f1 || true)
if [ -n "${ln:-}" ]; then
start=$((ln-20))
end=$((ln+25))
sed -n "${start},${end}p" "$f" | cat -n
else
echo "No signal.Notify found in $f (unexpected)."
fi
done
# 3) Inspect a bit more around cli/mcp/cmd.go signal.Notify block
echo "---- cli/mcp/cmd.go ----"
ln=$(rg -n "signal\.Notify\(sigCh" cli/mcp/cmd.go | head -n1 | cut -d: -f1 || true)
if [ -n "${ln:-}" ]; then
start=$((ln-30))
end=$((ln+40))
sed -n "${start},${end}p" cli/mcp/cmd.go | cat -n
fiRepository: NVIDIA/infra-controller-rest Length of output: 9983 Stop signal notifications on exit to avoid lingering signal registrations.
Proposed fix sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ defer signal.Stop(sigCh)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| select { | ||||||||||||||
| case err := <-errCh: | ||||||||||||||
| if err != nil && !errors.Is(err, http.ErrServerClosed) { | ||||||||||||||
| return fmt.Errorf("http server: %w", err) | ||||||||||||||
| } | ||||||||||||||
| return nil | ||||||||||||||
| case sig := <-sigCh: | ||||||||||||||
| opts.Log.Infof("nico-mcp: received %s, shutting down", sig) | ||||||||||||||
| ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) | ||||||||||||||
| defer cancel() | ||||||||||||||
| if err := httpServer.Shutdown(ctx); err != nil { | ||||||||||||||
| return fmt.Errorf("graceful shutdown: %w", err) | ||||||||||||||
| } | ||||||||||||||
| return nil | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // buildServeOptions resolves the urfave context into Options by layering | ||||||||||||||
| // app-global flags on top of the loaded config file, mirroring what the | ||||||||||||||
| // dynamically-generated commands do via clientFromContext. | ||||||||||||||
| func buildServeOptions(c *urfave.Context) (Options, error) { | ||||||||||||||
| cfg, _ := appcli.LoadConfig() | ||||||||||||||
| appcli.ApplyEnvOverrides(cfg) | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle config-loading errors instead of discarding them. Ignoring Proposed fix- cfg, _ := appcli.LoadConfig()
+ cfg, err := appcli.LoadConfig()
+ if err != nil {
+ return Options{}, fmt.Errorf("loading config: %w", err)
+ }
appcli.ApplyEnvOverrides(cfg)🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| baseURL := cfg.API.Base | ||||||||||||||
| if c.IsSet("base-url") { | ||||||||||||||
| baseURL = c.String("base-url") | ||||||||||||||
| } | ||||||||||||||
| if baseURL == "" { | ||||||||||||||
| baseURL = c.String("base-url") | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| org := cfg.API.Org | ||||||||||||||
| if c.IsSet("org") { | ||||||||||||||
| org = c.String("org") | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| apiName := cfg.API.Name | ||||||||||||||
| if c.IsSet("api-name") { | ||||||||||||||
| apiName = c.String("api-name") | ||||||||||||||
| } | ||||||||||||||
| if apiName == "" { | ||||||||||||||
| apiName = "nico" | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| token := "" | ||||||||||||||
| if c.IsSet("token") { | ||||||||||||||
| token = c.String("token") | ||||||||||||||
| } else if cfg.Auth.Token != "" { | ||||||||||||||
| token = cfg.Auth.Token | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| tokenCommand := "" | ||||||||||||||
| if c.IsSet("token-command") { | ||||||||||||||
| tokenCommand = c.String("token-command") | ||||||||||||||
| } else if cfg.Auth.TokenCommand != "" { | ||||||||||||||
| tokenCommand = cfg.Auth.TokenCommand | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| log := logrus.NewEntry(logrus.StandardLogger()) | ||||||||||||||
| if c.Bool("debug") { | ||||||||||||||
| log.Logger.SetLevel(logrus.DebugLevel) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return Options{ | ||||||||||||||
| BaseURL: baseURL, | ||||||||||||||
| Org: org, | ||||||||||||||
| APIName: apiName, | ||||||||||||||
| Token: token, | ||||||||||||||
| TokenCommand: tokenCommand, | ||||||||||||||
| Debug: c.Bool("debug"), | ||||||||||||||
| Log: log, | ||||||||||||||
| }, nil | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 7741
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 6440
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 13148
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 10016
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 106
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 106
🏁 Script executed:
Repository: NVIDIA/infra-controller-rest
Length of output: 12868
Validate
--pathbefore registering the handler.pathcomes directly from the flag and is passed tonet/http’sServeMux.Handle, which panics when the pattern is invalid. Enforce basic validity (e.g., non-empty and starting with/) before registering.Proposed fix
path := c.String("path") + if path == "" || path[0] != '/' { + return fmt.Errorf("invalid --path %q: must be non-empty and start with '/'", path) + } shutdownTimeout := c.Duration("shutdown-timeout") mux := http.NewServeMux() mux.Handle(path, NewHandler(server))📝 Committable suggestion
🤖 Prompt for AI Agents