Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions helm/rest/nico-mcp/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,14 @@ service:
type: ClusterIP
port: 8080

# MCP server configuration. The server is stateless and parameter-driven:
# per-call tool arguments and the inbound Authorization header normally supply
# the connection details, so the defaults below are optional and are typically
# left empty. Tokens are intentionally NOT exposed here -- supply them per-call
# or via the inbound Authorization header.
# MCP server configuration. Set baseURL when forwarding inbound Authorization
# headers; a per-call base_url must match it. When baseURL is empty, only a
# token supplied in the same call may be sent to a per-call base_url. Tokens
# are intentionally NOT exposed here.
config:
path: /mcp
shutdownTimeout: 10s
# -- Optional server-side default base URL (a per-call base_url overrides it)
# -- Trusted NICo REST base URL (a per-call base_url must match when set)
baseURL: ""
# -- Optional server-side default org (a per-call org overrides it)
org: ""
Expand Down
2 changes: 1 addition & 1 deletion rest-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ helm-deploy-site-agent:

# Deploy the standalone MCP server chart. The server is parameter-driven; for
# local dev we point its default base URL at the in-cluster API so a tool call
# only needs to supply org + token (a per-call base_url still overrides this).
# only needs to supply org + token (a per-call base_url must match this value).
helm-deploy-mcp:
helm upgrade --install nico-mcp $(MCP_CHART)/ \
--namespace nico-rest --create-namespace $(HELM_SET) \
Expand Down
10 changes: 5 additions & 5 deletions rest-api/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ Install the binaries with `make nico-cli` and `make nico-mcp`, run from the `res
- **Read-only.** Only `GET` operations are exposed. Mutating routes (`POST`, `PATCH`, `PUT`, `DELETE`) are intentionally excluded.
- **Tool naming.** Tools are named `nico_<snake_case(operationId)>` (e.g. `nico_get_all_site`, `nico_validate_rack`).
- **Stateless and request/response only.** The server sets `Stateless: true` and `JSONResponse: true` on the MCP streamable-HTTP handler -- responses are always `Content-Type: application/json`, never `text/event-stream`, and the server retains no per-session state.
- **JWT passthrough.** The `Authorization: Bearer <jwt>` header on the inbound MCP request is forwarded unchanged to NICo REST. NICo REST validates the JWT, resolves the caller org, and enforces role-based authorization. The MCP layer never makes the authz decision itself.
- **JWT passthrough.** The `Authorization: Bearer <jwt>` header on the inbound MCP request is forwarded unchanged only to the server's configured NICo REST base URL. NICo REST validates the JWT, resolves the caller org, and enforces role-based authorization. The MCP layer never makes the authz decision itself.

### Flags

Expand All @@ -384,11 +384,11 @@ Install the binaries with `make nico-cli` and `make nico-mcp`, run from the `res
| `--path` | `NICO_MCP_PATH` | HTTP path the MCP handler is mounted at (default `/mcp`) |
| `--shutdown-timeout` | `NICO_MCP_SHUTDOWN_TIMEOUT` | Graceful shutdown timeout (default `10s`) |

`--base-url`, `--org`, `--api-name`, and `--token` are accepted directly by `nico-mcp` and provide optional server-side defaults; each also reads its `NICO_*` environment variable. The MCP server does **not** read `~/.nico/config.yaml`: it is stateless and entirely parameter-driven, so it starts cleanly with no config file present and every connection detail is supplied per tool call (see below), falling back to these flags only when an argument is omitted.
`--base-url`, `--org`, `--api-name`, and `--token` are accepted directly by `nico-mcp`; each also reads its `NICO_*` environment variable. `--base-url` pins the trusted NICo REST destination, so a per-call `base_url` must match it when configured. Without `--base-url`, a tool call may select its destination and proceed without credentials or with an explicit per-call token; only inherited credentials from the inbound Authorization header or server default token are rejected rather than forwarded. The MCP server does **not** read `~/.nico/config.yaml` and starts cleanly with no config file present.

### Per-call config overrides
### Per-call config

Every typical config value can also be passed as an argument on each MCP tool call, layered on top of the server defaults:
Connection values can be passed on each MCP tool call. `org`, `api_name`, and `token` override server defaults; `base_url` supplies the destination only when no server base URL is configured and otherwise must match it:

| Tool arg | Equivalent flag | Config field |
|----------|-----------------|--------------|
Expand All @@ -397,7 +397,7 @@ Every typical config value can also be passed as an argument on each MCP tool ca
| `api_name` | `--api-name` | `api.name` |
| `token` | `--token` | `auth.token` |

Precedence per tool call (first non-empty wins): tool argument -> inbound `Authorization` header (token only) -> server startup flag/env. The MCP server does not read the on-disk config file. OIDC credentials and NGC api_key settings are NOT exposed as tool arguments -- they are login-flow inputs configured server-side via flags/env.
Token precedence per tool call is: tool argument -> inbound `Authorization` header -> server startup flag/env. Inbound and startup tokens are eligible only when a server base URL is configured; a caller-selected destination can use an explicit per-call token or no token. The MCP server does not read the on-disk config file or resolve OIDC and NGC credentials; clients or an upstream gateway must supply the resolved bearer through the per-call `token` argument or inbound `Authorization` header.

### Probing the server

Expand Down
14 changes: 8 additions & 6 deletions rest-api/cli/pkg/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,19 @@ Common flags (each also reads its NICO_* environment variable):

--listen address:port to listen on (default ":8080")
--path HTTP path the MCP handler is mounted at (default "/mcp")
--base-url default NICo REST base URL
--base-url trusted NICo REST base URL; per-call base_url must match
--org default org used in /v2/org/<org>/... paths
--api-name API path segment in /v2/org/<org>/<name>/... (default "nico")
--token default bearer token
--token default bearer token for the configured base URL
--shutdown-timeout graceful shutdown timeout (default 10s)
--debug enable debug logging

The server is stateless: base_url, org, and token can be supplied per tool
call (token also via an inbound Authorization header), with the flags above as
defaults. Point your MCP client at http://<listen><path> (default
http://localhost:8080/mcp). Run "nico-mcp --help" for the full flag list.
The server is stateless. A configured base URL pins the destination and is the
only destination that may receive an inbound or default bearer token. Without
one, a per-call base_url may use an explicit per-call token or no token; the
server rejects inherited credentials. Point your MCP client at
http://<listen><path> (default http://localhost:8080/mcp). Run "nico-mcp
--help" for the full flag list.
`

func printMCPInstructions(c *cli.Context) error {
Expand Down
6 changes: 3 additions & 3 deletions rest-api/mcp/cmd/nico-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ func main() {
Description: "Serves the NICo REST read surface as MCP tools at the configured\n" +
"path and listen address. The server is stateless and never emits\n" +
"text/event-stream responses; every tool/call returns a single JSON\n" +
"body. Authentication is per-call: a token argument or the inbound\n" +
"Authorization header is forwarded to NICo REST, which makes the\n" +
"authorization decision.",
"body. Inbound or default credentials are forwarded only to the\n" +
"configured NICo REST base URL. A caller-selected base_url can use\n" +
"only a token supplied in the same tool call.",
Flags: server.ServeFlags(),
Action: func(c *cli.Context) error {
return server.Run(c, openapi.Spec)
Expand Down
62 changes: 38 additions & 24 deletions rest-api/mcp/internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ import (
"github.com/sirupsen/logrus"
)

// Options carry the server-side defaults that every tool invocation
// starts from. Individual tool calls override these per request through
// resolveCallConfig.
// Options carry the server-side settings that tool invocations resolve against
// through FromCallConfig. BaseURL also bounds the allowed destination.
type Options struct {
// BaseURL is the NICo REST base URL (e.g. https://nico.example.com).
// BaseURL is the trusted NICo REST base URL (e.g. https://nico.example.com).
// When set, a per-call base_url must resolve to the same value.
BaseURL string
// Org is the default organisation used in /v2/org/<org>/... paths.
Org string
// APIName is the API path segment between org and resource (default
// "nico", overridable via api.name in config).
APIName string
// Token is the static bearer used when no inbound bearer or tool
// arg token is provided.
// Token is the static bearer used with BaseURL when no inbound bearer or
// tool arg token is provided. It is not sent to a caller-selected base URL.
Token string
// Debug enables logrus debug-level HTTP request/response logging
// through to the appcli.Client.
Expand All @@ -47,22 +47,22 @@ func (o Options) withDefaults() Options {
return o
}

// commonConfigDescriptions documents the four per-call config overrides
// that are merged into every tool's input schema. Kept as a slice (not
// commonConfigDescriptions documents the four per-call config fields that are
// merged into every tool's input schema. Kept as a slice (not
// a map) so the schema render order is stable.
var commonConfigDescriptions = []struct {
Name string
Desc string
}{
{"org", "Org used in /v2/org/<org>/... paths for this call. Overrides the server startup flag/env default when set."},
{"base_url", "NICo REST base URL for this call. Overrides the server startup flag/env default when set; useful when one MCP server fronts multiple NICo REST deployments."},
{"base_url", "NICo REST base URL for this call. When the server has a configured base URL, this value must match it. Otherwise, only a token supplied in the same tool call may be sent to this destination."},
{"api_name", "Override the API path segment used in /v2/org/<org>/<name>/... (api.name; default \"nico\")."},
{"token", "Bearer token for this call. Overrides the inbound Authorization header. Omit it when an upstream proxy injects the Authorization header, which is passed through to NICo REST unchanged."},
{"token", "Bearer token for this call. Overrides the inbound Authorization header. Inbound or default credentials are forwarded only to the server's configured base URL."},
}

// resolvedConfig is the result of merging Options with the per-call
// overrides for one tool invocation. It is consumed by registerGET to
// construct a fresh appcli.Client.
// resolvedConfig is the result of resolving Options with the per-call values
// for one tool invocation. It is consumed by registerGET to construct a fresh
// appcli.Client.
type resolvedConfig struct {
BaseURL string
Org string
Expand All @@ -74,28 +74,42 @@ type resolvedConfig struct {
// documented in the design plan:
//
// 1. Tool-call argument (org, base_url, api_name, token)
// 2. Inbound Authorization header (token only)
// 2. Inbound Authorization header (token only, for a configured BaseURL)
// 3. Server startup flag / Options (BaseURL, Org, APIName, Token)
//
// It returns an error when a required field (org, base_url) ends up
// empty so the tool handler can surface a JSON-RPC error instead of
// letting the call go out with an invalid URL.
// A configured BaseURL binds all calls to that destination. Without one,
// inherited inbound or default credentials are not accepted for a per-call
// destination; if authentication is needed, callers must provide the token in
// the same tool call. It returns an error when this policy is violated or a
// required field (org, base_url) ends up empty, before the handler constructs
// an outbound request.
func (cfg *resolvedConfig) FromCallConfig(in map[string]any, req *mcp.CallToolRequest, opts Options) error {
cfg.BaseURL = normalizeBaseURL(cmp.Or(stringArg(in, "base_url"), opts.BaseURL))
callBaseURL := normalizeBaseURL(stringArg(in, "base_url"))
configuredBaseURL := normalizeBaseURL(opts.BaseURL)
if configuredBaseURL != "" && callBaseURL != "" && !sameBaseURL(callBaseURL, configuredBaseURL) {
return fmt.Errorf("per-call base_url does not match the configured server base URL")
}

callToken := stringArg(in, "token")
inboundToken := bearerFromExtra(req)
if configuredBaseURL == "" && callBaseURL != "" && callToken == "" &&
(inboundToken != "" || opts.Token != "") {
return fmt.Errorf("refusing to forward inherited credentials to a per-call base_url; pass token in the same tool call or configure the server base URL")
}

cfg.BaseURL = cmp.Or(callBaseURL, configuredBaseURL)
cfg.Org = cmp.Or(stringArg(in, "org"), opts.Org)
cfg.APIName = cmp.Or(stringArg(in, "api_name"), opts.APIName)
cfg.Token = normalizeToken(cmp.Or(
stringArg(in, "token"),
bearerFromExtra(req),
callToken,
inboundToken,
opts.Token,
))
return cfg.requireNonEmpty()
}
Comment thread
kfelternv marked this conversation as resolved.

// requireNonEmpty returns a descriptive error when org or BaseURL are
// blank. Token can be empty -- NICo REST will reject the request with
// 401 and the response surfaces to the caller as an MCP error result;
// that path is exercised by the bearer-passthrough integration test.
// requireNonEmpty returns a descriptive error when org or BaseURL are blank.
// Token may be empty; appcli.Client then sends no Authorization header.
func (c resolvedConfig) requireNonEmpty() error {
missing := []string{}
if c.Org == "" {
Expand Down
114 changes: 106 additions & 8 deletions rest-api/mcp/internal/server/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func TestFromCallConfig_PrecedenceChain(t *testing.T) {
type expect struct {
baseURL, org, apiName, token string
wantErr bool
errContains string
}
cases := []struct {
name string
Expand All @@ -24,22 +24,120 @@ func TestFromCallConfig_PrecedenceChain(t *testing.T) {
expected expect
}{
{
name: "tool_args_win_every_field",
name: "tool_args_resolve_every_field_without_configured_base_url",
in: map[string]any{
"base_url": "https://from-arg.example.com/",
"org": "arg-org",
"api_name": "arg-name",
"token": "Bearer arg-token",
},
req: requestWithBearer("inbound-bearer"),
opts: Options{BaseURL: "https://opts.example.com", Org: "opts-org", APIName: "opts-name", Token: "opts-token"},
opts: Options{Org: "opts-org", APIName: "opts-name", Token: "opts-token"},
expected: expect{
baseURL: "https://from-arg.example.com",
org: "arg-org",
apiName: "arg-name",
token: "arg-token",
},
},
{
name: "configured_base_url_rejects_different_call_destination",
in: map[string]any{
"base_url": "https://from-arg.example.com",
"org": "arg-org",
"token": "arg-token",
},
req: requestWithBearer("inbound-bearer"),
opts: Options{BaseURL: "https://opts.example.com", Org: "opts-org", Token: "opts-token"},
expected: expect{
errContains: "does not match the configured server base URL",
},
},
{
name: "configured_base_url_accepts_matching_normalized_call_destination",
in: map[string]any{
"base_url": "https://opts.example.com///",
},
req: requestWithBearer("from-header"),
opts: Options{BaseURL: "https://opts.example.com/", Org: "opts-org", APIName: "nico", Token: "opts-token"},
expected: expect{
baseURL: "https://opts.example.com",
org: "opts-org",
apiName: "nico",
token: "from-header",
},
},
{
name: "configured_base_url_accepts_matching_scheme_and_host_case",
in: map[string]any{
"base_url": "https://OPTS.example.com/v2/",
},
req: requestWithBearer("from-header"),
opts: Options{BaseURL: "HTTPS://opts.example.com/v2", Org: "opts-org", APIName: "nico"},
expected: expect{
baseURL: "https://OPTS.example.com/v2",
org: "opts-org",
apiName: "nico",
token: "from-header",
},
},
{
name: "configured_base_url_accepts_matching_explicit_default_port",
in: map[string]any{
"base_url": "https://opts.example.com/v2",
},
req: requestWithBearer("from-header"),
opts: Options{BaseURL: "https://opts.example.com:443/v2", Org: "opts-org", APIName: "nico"},
expected: expect{
baseURL: "https://opts.example.com/v2",
org: "opts-org",
apiName: "nico",
token: "from-header",
},
},
{
name: "configured_base_url_rejects_different_path_case",
in: map[string]any{
"base_url": "https://opts.example.com/V2",
},
opts: Options{BaseURL: "https://opts.example.com/v2", Org: "opts-org", APIName: "nico"},
expected: expect{
errContains: "does not match the configured server base URL",
},
},
{
name: "dynamic_destination_rejects_inbound_bearer_without_call_token",
in: map[string]any{
"base_url": "https://from-arg.example.com",
},
req: requestWithBearer("from-header"),
opts: Options{Org: "opts-org", APIName: "nico"},
expected: expect{
errContains: "refusing to forward inherited credentials",
},
},
{
name: "dynamic_destination_rejects_default_token_without_call_token",
in: map[string]any{
"base_url": "https://from-arg.example.com",
},
opts: Options{Org: "opts-org", APIName: "nico", Token: "opts-token"},
expected: expect{
errContains: "refusing to forward inherited credentials",
},
},
{
name: "dynamic_destination_without_credentials_is_allowed",
in: map[string]any{
"base_url": "https://from-arg.example.com/",
},
opts: Options{Org: "opts-org", APIName: "nico"},
expected: expect{
baseURL: "https://from-arg.example.com",
org: "opts-org",
apiName: "nico",
},
},
{
name: "inbound_bearer_wins_when_no_token_arg",
in: map[string]any{},
Expand Down Expand Up @@ -70,7 +168,7 @@ func TestFromCallConfig_PrecedenceChain(t *testing.T) {
req: nil,
opts: Options{BaseURL: "https://opts.example.com"},
expected: expect{
wantErr: true,
errContains: "missing required config value(s): org",
},
},
{
Expand All @@ -79,7 +177,7 @@ func TestFromCallConfig_PrecedenceChain(t *testing.T) {
req: nil,
opts: Options{Org: "opts-org"},
expected: expect{
wantErr: true,
errContains: "missing required config value(s): base_url",
},
},
{
Expand All @@ -88,7 +186,7 @@ func TestFromCallConfig_PrecedenceChain(t *testing.T) {
req: nil,
opts: Options{},
expected: expect{
wantErr: true,
errContains: "missing required config value(s): org, base_url",
},
},
{
Expand Down Expand Up @@ -129,8 +227,8 @@ func TestFromCallConfig_PrecedenceChain(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
var cfg resolvedConfig
err := cfg.FromCallConfig(c.in, c.req, c.opts)
if c.expected.wantErr {
require.Error(t, err)
if c.expected.errContains != "" {
require.ErrorContains(t, err, c.expected.errContains)
return
}
require.NoError(t, err)
Expand Down
Loading
Loading