Company MCP servers are registered with ctl mcp, and tools granted with keys limits --tools
CI / test (push) Failing after 5m4s

llmgw ctl mcp add|list|remove|test and /api/v1/mcp/servers keep the
servers; a token given with --secret-file is kept in the store, sealed,
and named by reference. keys limits --tools, and the console's limits
form, grant tools as patterns. Documented in install.md and clients.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-20 12:50:55 +02:00
1 parent ca77637a71
commit 4453f469e1
16 files changed
+893 -9

No files matched your search

+8
View File
@@ -1518,6 +1518,14 @@ password" in forty wordings asks the model forty times.
point of the run with it off.
### I2. MCP gateway: the customer's tool servers behind llmgw (L)
P1 done 2026-10-20: HTTP servers registered (`ctl mcp`, `/api/v1/mcp/servers`,
store `mcp_servers`), tools as `server.tool` in `/mcp`, `allowed_tools`
grants in the tree (deny by default), credentials in the store or by
reference, the caller named in `X-Llmgw-*` headers, time and size bounds,
progress passed on, audit (Source `mcp`) and metrics. Left: P2, the
guardrails both ways, a per-tool rate limit, a breaker, and a portal page;
P3, stdio servers and per-person OAuth. The "done when" with Claude Code
against a real server is still to try.
`/mcp` serves llmgw's own tools. An agent inside a company also wants Jira,
the file server, a database — each an MCP server, each today configured
in every client with its own credentials and nobody seeing what was called.
+8
View File
@@ -28,6 +28,14 @@ APIs change between minors without a deprecation period.
### Added
- **The company's own MCP servers through llmgw's `/mcp`.** `llmgw ctl mcp add jira --url … --secret-file
Authorization=FILE` registers one; `/mcp` then lists its tools as `jira.search` beside llmgw's own. Only keys granted
them see them: `llmgw keys limits --tools 'jira.*'`, patterns at any level of the tenancy tree. None granted anywhere
is none. The server's credentials stay on the control planes, kept in the store sealed or read by reference. The
server is told the calling key, person and project. Each call is bounded in time and size, and its progress passes
on to a streaming client. Every call is in the audit log with its arguments' size and digest, never their text. The
admin API has `/api/v1/mcp/servers`.
- **Large models across cards and nodes.** For a model of experts on more than one card, the plan now adds vLLM's
`--enable-expert-parallel`. It reads the expert count from `config.json` under any of its names, and sizes a model
that also reads pictures by its `text_config`. Several nodes are offered in any number, not only powers of two. Each
+261
View File
@@ -65,6 +65,9 @@
{
"name": "keys"
},
{
"name": "mcp"
},
{
"name": "metrics"
},
@@ -1936,6 +1939,170 @@
}
}
},
"/api/v1/mcp/servers": {
"get": {
"operationId": "getMcpServers",
"summary": "The company's MCP servers /mcp serves, and how each is reached",
"tags": [
"mcp"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MCPServerView"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/mcp/servers/{name}": {
"put": {
"operationId": "putMcpServersName",
"summary": "Add an MCP server, or change one: its address, headers and the secrets they carry",
"tags": [
"mcp"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The MCP server's name: its tools are NAME.tool in /mcp.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPServerRequest"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPServerView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
},
"delete": {
"operationId": "deleteMcpServersName",
"summary": "Remove an MCP server: its tools leave /mcp",
"tags": [
"mcp"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The MCP server's name: its tools are NAME.tool in /mcp.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/mcp/servers/{name}/test": {
"post": {
"operationId": "postMcpServersNameTest",
"summary": "Ask an MCP server for its tools, with its credentials, as /mcp would",
"tags": [
"mcp"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The MCP server's name: its tools are NAME.tool in /mcp.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MCPTestView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/metrics/range": {
"post": {
"operationId": "postMetricsRange",
@@ -7190,6 +7357,100 @@
}
}
},
"MCPServerRequest": {
"type": "object",
"description": "MCPServerRequest adds a server or changes one. Secrets are header values to keep in the store, sealed, each then read by reference; HeaderRefs name secrets kept elsewhere (a file, env:, vault:).",
"properties": {
"url": {
"type": "string"
},
"description": {
"type": "string"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"header_refs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"secrets": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"enabled": {
"type": "boolean"
},
"timeout_seconds": {
"type": "integer"
}
}
},
"MCPServerView": {
"type": "object",
"description": "MCPServerView is a server as the API shows it: header values only for the headers sent as written, and for the others the reference read.",
"properties": {
"name": {
"type": "string"
},
"url": {
"type": "string"
},
"description": {
"type": "string"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"header_refs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"enabled": {
"type": "boolean"
},
"timeout_seconds": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"created_by": {
"type": "string"
}
}
},
"MCPTestView": {
"type": "object",
"description": "MCPTestView is what the server said when asked: its tools, or why not.",
"properties": {
"ok": {
"type": "boolean"
},
"tools": {
"type": "array",
"items": {
"type": "string"
}
},
"error": {
"type": "string"
}
}
},
"MembershipRequest": {
"type": "object",
"description": "MembershipRequest puts a person in an organization, team or project, or with DELETE takes them out.",
+9
View File
@@ -579,6 +579,15 @@ claude mcp add --transport http llmgw https://llm.example.com/mcp \
--header "Authorization: Bearer $LLMGW_API_KEY"
```
**Your company's tools.** Where an administrator has registered the
company's own MCP servers (Jira, a file server, a database) and your key is
granted their tools, the same `/mcp` lists them too, each as `server.tool`
(`jira.search`). Give your agent only llmgw's `/mcp` and your key: the
servers' own credentials stay with llmgw. A tool your key is not granted is
not listed, and calling it by name is refused. Each call is in the audit
log under your key: which tool, the size of what was sent, how it ended and
how long it took, never what it said.
### Licences you leave out
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
+44
View File
@@ -2496,6 +2496,50 @@ the same files and keys as `locales/en/`, and its `_meta.*` entries saying how
it writes numbers and dates. The tests refuse a catalogue that lacks a key a
page uses.
## The company's MCP servers
llmgw's `/mcp` can serve the company's own MCP servers beside its own tools.
Each server, a ticket system, a file server or a database, is registered once.
Its credentials stay on the control planes, and people's agents are given only
`/mcp` and their llmgw key.
```
llmgw ctl mcp add jira --url https://jira.internal/mcp \
--description "Acme Jira" --secret-file Authorization=/root/jira-token
llmgw ctl mcp test jira # its tools, asked with its credentials
llmgw keys limits --scope team --id Acme/support --tools 'jira.*'
```
- **Credentials:**
- `--secret-file NAME=FILE` reads a header's value once and keeps it in
the store, sealed by the master key, so no file has to be on each
control plane.
- `--header-ref NAME=REF` reads it at each call from a file, `env:`,
`vault:` or `store:` instead.
- `--header NAME=VALUE` is for what is not secret.
- **Who may call what:** tools are named `server.tool` and granted as
patterns (`jira.*`, `db.query`) at any level of the tenancy tree, as
models are.
- A level that names any narrows what is above it.
- Unlike models, **none granted anywhere is none**: a tool reaches into a
company system, so it is granted, never assumed.
- The console's limits form has the same field.
- **The server is told who calls:** `X-Llmgw-Key`, and `X-Llmgw-User` and
`X-Llmgw-Project` where there are some. It is called with llmgw's
credentials, never the caller's key.
- **Bounds:** a call has 60 seconds (`--timeout`) and an answer 4 MB. A
server's progress is passed on to a client that takes a stream.
- **Record:** every call is in `llmgw ctl audit`, with its arguments' size
and a digest, never their text. `llmgw_mcp_tool_calls_total` and
`llmgw_mcp_tool_call_seconds` count them by server, tool and outcome.
- **Changes:** a server added, changed or removed on one control plane is
served by every one within half a minute; its tool list is read again
every five minutes. `--disabled` keeps a server but serves none of its
tools.
Servers over HTTP only for now (the streamable HTTP transport). A server
that runs as a local process (stdio) is planned.
## Single sign-on
People can sign in through your identity provider — Keycloak, Entra ID,
+1
View File
@@ -186,6 +186,7 @@ func newCtlCmd() *cobra.Command {
newCapacityCmd(client, &asJSON),
newQuotaCmd(client, &asJSON),
newGuardCmd(client, &asJSON),
newMCPCmd(client, &asJSON),
newKBCmd(client, &asJSON),
newTrainCmd(client, &asJSON),
newConfigCmd(client, &asJSON),
+187
View File
@@ -0,0 +1,187 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func newMCPCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "mcp",
Short: "The company's own MCP servers, served through llmgw's /mcp",
Long: "An MCP server registered here — Jira, a file server, a database — is served\n" +
"through llmgw's /mcp beside llmgw's own tools, its tools named SERVER.tool.\n" +
"Its credentials stay on the control planes: a client gives only its llmgw\n" +
"key. A key sees and calls only the tools granted it, as patterns, with\n" +
"`llmgw keys limits --tools 'jira.*'` at any level of the tenancy tree;\n" +
"none granted anywhere is none. Every call is in `llmgw ctl audit`.",
}
c.AddCommand(newMCPAddCmd(client, asJSON), newMCPListCmd(client, asJSON), newMCPRemoveCmd(client),
newMCPTestCmd(client, asJSON))
return c
}
func newMCPAddCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
var req adminapi.MCPServerRequest
var headers, refs, secretFiles []string
var disabled bool
c := &cobra.Command{
Use: "add NAME --url URL",
Short: "Register an MCP server, or change one",
Example: " llmgw ctl mcp add jira --url https://jira.internal/mcp --secret-file Authorization=/root/jira-token\n" +
" llmgw ctl mcp add wiki --url https://wiki.internal/mcp --header-ref Authorization=vault:secret/mcp/wiki#token\n" +
" llmgw ctl mcp add jira --disabled # off, kept",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var err error
if req.Headers, err = pairs(headers, "--header"); err != nil {
return err
}
if req.HeaderRefs, err = pairs(refs, "--header-ref"); err != nil {
return err
}
files, err := pairs(secretFiles, "--secret-file")
if err != nil {
return err
}
// A secret read here and kept in the store, sealed: the file
// need not be on every control plane, and is not read again.
for header, file := range files {
b, err := os.ReadFile(file) //nolint:gosec // a file the operator names
if err != nil {
return err
}
if req.Secrets == nil {
req.Secrets = map[string]string{}
}
req.Secrets[header] = strings.TrimSpace(string(b))
}
if cmd.Flags().Changed("disabled") {
on := !disabled
req.Enabled = &on
}
body, _ := json.Marshal(req)
var v adminapi.MCPServerView
if err := client().put(cmd.Context(), "/api/v1/mcp/servers/"+args[0], body, &v); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
}
state := "on"
if !v.Enabled {
state = "off"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s, %s. Its tools are %s.TOOL in /mcp, to keys granted them "+
"(llmgw keys limits --tools '%s.*'). Check it: llmgw ctl mcp test %s\n", v.Name, v.URL, state, v.Name, v.Name, v.Name)
return nil
},
}
f := c.Flags()
f.StringVar(&req.URL, "url", "", "the server's streamable HTTP address, https://…/mcp")
f.StringVar(&req.Description, "description", "", "what it is, shown beside its tools")
f.StringArrayVar(&headers, "header", nil, "NAME=VALUE sent as written, for what is not secret; repeatable")
f.StringArrayVar(&refs, "header-ref", nil, "NAME=REF read at each call: a file on every control plane, env:, vault: or store:; repeatable")
f.StringArrayVar(&secretFiles, "secret-file", nil, "NAME=FILE read now and kept in the store, sealed: a token; repeatable")
f.BoolVar(&disabled, "disabled", false, "keep it but serve none of its tools")
f.IntVar(&req.TimeoutSeconds, "timeout", 0, "seconds one call may take (default 60)")
return c
}
func newMCPListCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "The MCP servers /mcp serves",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var list []adminapi.MCPServerView
if err := client().get(cmd.Context(), "/api/v1/mcp/servers", &list); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(list)
}
if len(list) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "no MCP servers; add one: llmgw ctl mcp add NAME --url URL")
return nil
}
w := tabTo(cmd.OutOrStdout())
fmt.Fprintln(w, "NAME\tSTATE\tURL\tCREDENTIALS\tDESCRIPTION")
for _, s := range list {
state := "on"
if !s.Enabled {
state = "off"
}
var creds []string
for h := range s.HeaderRefs {
creds = append(creds, h)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, state, s.URL, dash(strings.Join(creds, ",")), s.Description)
}
return w.Flush()
},
}
}
func newMCPRemoveCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "remove NAME",
Short: "Remove an MCP server: its tools leave /mcp",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := client().delete(cmd.Context(), "/api/v1/mcp/servers/"+args[0], nil); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s removed; its tools leave /mcp within half a minute\n", args[0])
return nil
},
}
}
func newMCPTestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "test NAME",
Short: "Ask an MCP server for its tools, with its credentials, as /mcp would",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var v adminapi.MCPTestView
if err := client().post(cmd.Context(), "/api/v1/mcp/servers/"+args[0]+"/test", nil, &v); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
}
if !v.OK {
return errors.New(args[0] + " did not answer: " + v.Error)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s answers with %d tool(s):\n", args[0], len(v.Tools))
for _, t := range v.Tools {
fmt.Fprintln(cmd.OutOrStdout(), " "+t)
}
return nil
},
}
}
// pairs reads NAME=VALUE flags.
func pairs(list []string, flag string) (map[string]string, error) {
if len(list) == 0 {
return nil, nil
}
out := map[string]string{}
for _, p := range list {
k, v, ok := strings.Cut(p, "=")
if !ok || strings.TrimSpace(k) == "" {
return nil, fmt.Errorf("%s %q is not NAME=VALUE", flag, p)
}
out[strings.TrimSpace(k)] = v
}
return out, nil
}
+12 -1
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"math"
"path"
"slices"
"strings"
"text/tabwriter"
@@ -301,7 +302,7 @@ func newLimitsCmd(open opener) *cobra.Command {
var perMember bool
var rpm, tpm int
var budget float64
var models []string
var models, tools []string
cmd := &cobra.Command{
Use: "limits",
@@ -379,6 +380,14 @@ func newLimitsCmd(open opener) *cobra.Command {
}
l.AllowedModels = models
}
if cmd.Flags().Changed("tools") {
for _, p := range tools {
if _, err := path.Match(p, "x.y"); err != nil {
return fmt.Errorf("--tools %q is not a pattern: %w", p, err)
}
}
l.AllowedTools = tools
}
if err := s.PutLimits(cmd.Context(), l); err != nil {
return err
}
@@ -395,6 +404,8 @@ func newLimitsCmd(open opener) *cobra.Command {
cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them")
cmd.Flags().StringVar(&period, "period", "month", "day | month")
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
cmd.Flags().StringSliceVar(&tools, "tools", nil,
"the company MCP servers' tools granted here, as patterns: jira.*,db.query; none granted anywhere is none")
return cmd
}
+1
View File
@@ -439,6 +439,7 @@ func newServerCmd() *cobra.Command {
}
// The cluster's settings, each change checked against this
// machine's own keys before it is kept.
api.SetMCP(st)
api.SetAuth(&adminapi.AuthAdmin{Status: rt.AuthStatus, TestLDAP: rt.TestDirectory, Secrets: st, Log: log})
api.SetSettings(&adminapi.Settings{Store: st, LocalKeys: config.LocalKeys(),
Check: func(body []byte) error { return checkClusterSettings(configPath, body) }})
+8
View File
@@ -395,6 +395,7 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
if l.AllowedModels != nil {
models = strings.Join(l.AllowedModels, ",")
}
tools := strings.Join(l.AllowedTools, ",")
m.openForm(&formDialog{title: "Limits on " + who,
note: "Each level only narrows what is above it. Empty is no limit here.",
fields: []formField{
@@ -403,6 +404,7 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
textF("budget", budget, "spend per period, in whole units"),
choiceF("per", []string{"day", "month"}, period),
textF("models", models, "allowed, separated by commas; \"every model\" for no list"),
textF("company tools", tools, "MCP tools granted here, as jira.*,db.query; empty grants none here"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
args := []string{"keys", "limits", "--scope=" + string(scope), "--id=" + id}
@@ -423,6 +425,9 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
if v[4] != "every model" {
args = append(args, "--models="+strings.ReplaceAll(v[4], " ", ""))
}
if t := strings.ReplaceAll(v[5], " ", ""); t != "" {
args = append(args, "--tools="+t)
}
return m.runCLI("Limits on "+who, args...), nil
}})
}
@@ -761,5 +766,8 @@ func limitsCell(l store.Limits) string {
if l.AllowedModels != nil {
p = append(p, fmt.Sprintf("%d models", len(l.AllowedModels)))
}
if len(l.AllowedTools) > 0 {
p = append(p, fmt.Sprintf("%d tool grants", len(l.AllowedTools)))
}
return strings.Join(p, ", ")
}
+2
View File
@@ -77,6 +77,7 @@ type API struct {
train *TrainAdmin
settings *Settings
auth *AuthAdmin
mcp MCPStore
// restartAgents tells every connected agent to start again.
restartAgents func(reason string) []string
// revoke records a removed node, so its certificate is refused.
@@ -131,6 +132,7 @@ func (a *API) Register(mux Mux) {
a.registerGuard(mux)
a.registerSettings(mux)
a.registerAuth(mux)
a.registerMCP(mux)
mux.HandleFunc("POST /api/v1/agents/restart", a.restartAgentsHandler)
mux.HandleFunc("GET /api/v1/audit", a.auditEvents)
mux.HandleFunc("GET /api/v1/usage/export", a.usageExport)
+226
View File
@@ -0,0 +1,226 @@
package adminapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"sort"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// MCPStore keeps the company's MCP servers and the secrets their headers
// carry.
type MCPStore interface {
MCPServers(ctx context.Context) ([]store.MCPServer, error)
MCPServer(ctx context.Context, name string) (store.MCPServer, error)
PutMCPServer(ctx context.Context, m store.MCPServer) error
DeleteMCPServer(ctx context.Context, name string) error
SetClusterSecret(ctx context.Context, name, value string) error
}
// SetMCP enables the endpoints for the company's MCP servers.
func (a *API) SetMCP(st MCPStore) { a.mcp = st }
// MCPServerView is a server as the API shows it: header values only for the
// headers sent as written, and for the others the reference read.
type MCPServerView struct {
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
HeaderRefs map[string]string `json:"header_refs,omitempty"`
Enabled bool `json:"enabled"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
// MCPServerRequest adds a server or changes one. Secrets are header values
// to keep in the store, sealed, each then read by reference; HeaderRefs name
// secrets kept elsewhere (a file, env:, vault:).
type MCPServerRequest struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
HeaderRefs map[string]string `json:"header_refs,omitempty"`
Secrets map[string]string `json:"secrets,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// MCPTestView is what the server said when asked: its tools, or why not.
type MCPTestView struct {
OK bool `json:"ok"`
Tools []string `json:"tools,omitempty"`
Error string `json:"error,omitempty"`
}
func mcpServerView(m store.MCPServer) MCPServerView {
return MCPServerView{Name: m.Name, URL: m.URL, Description: m.Description, Headers: m.Headers,
HeaderRefs: m.HeaderRefs, Enabled: m.Enabled, TimeoutSeconds: int(m.Timeout / time.Second),
CreatedAt: m.CreatedAt, CreatedBy: m.CreatedBy}
}
func (a *API) registerMCP(mux Mux) {
need := func(w http.ResponseWriter) bool {
if a.mcp == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store to keep MCP servers in")
return false
}
return true
}
mux.HandleFunc("GET /api/v1/mcp/servers", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
list, err := a.mcp.MCPServers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]MCPServerView, len(list))
for i, m := range list {
out[i] = mcpServerView(m)
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("PUT /api/v1/mcp/servers/{name}", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
name := r.PathValue("name")
var req MCPServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
m, err := a.mcpServerFrom(r.Context(), name, req, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := a.mcp.PutMCPServer(r.Context(), m); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, mcpServerView(m))
})
mux.HandleFunc("DELETE /api/v1/mcp/servers/{name}", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
switch err := a.mcp.DeleteMCPServer(r.Context(), r.PathValue("name")); {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "no MCP server "+r.PathValue("name"))
case err != nil:
writeError(w, http.StatusInternalServerError, err.Error())
default:
writeJSON(w, http.StatusOK, map[string]string{"removed": r.PathValue("name")})
}
})
mux.HandleFunc("POST /api/v1/mcp/servers/{name}/test", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
m, err := a.mcp.MCPServer(r.Context(), r.PathValue("name"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "no MCP server "+r.PathValue("name"))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, testMCPServer(r.Context(), m))
})
}
// mcpServerFrom is the server a request makes, over the one of that name if
// there is one: its secrets kept in the store, and named by reference.
func (a *API) mcpServerFrom(ctx context.Context, name string, req MCPServerRequest, by string) (store.MCPServer, error) {
if !store.ValidMCPServerName(name) {
return store.MCPServer{}, errors.New("a server's name is lowercase letters, digits, - and _, starting with a letter: " +
"its tools are NAME.tool")
}
m, err := a.mcp.MCPServer(ctx, name)
switch {
case errors.Is(err, store.ErrNotFound):
m = store.MCPServer{Name: name, Enabled: true, CreatedBy: by}
case err != nil:
return m, err
}
if req.URL != "" {
m.URL = req.URL
}
if u, err := url.Parse(m.URL); err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return m, errors.New("the server's url is an http:// or https:// address")
}
if req.Description != "" {
m.Description = req.Description
}
if req.Headers != nil {
m.Headers = req.Headers
}
if req.HeaderRefs != nil {
m.HeaderRefs = req.HeaderRefs
}
if m.HeaderRefs == nil {
m.HeaderRefs = map[string]string{}
}
for header, value := range req.Secrets {
if strings.TrimSpace(header) == "" || value == "" {
return m, errors.New("a secret header needs a name and a value")
}
key := "mcp/" + name + "/" + header
if err := a.mcp.SetClusterSecret(ctx, key, value); err != nil {
return m, err
}
m.HeaderRefs[header] = "store:" + key
delete(m.Headers, header)
}
if req.Enabled != nil {
m.Enabled = *req.Enabled
}
if req.TimeoutSeconds > 0 {
m.Timeout = time.Duration(req.TimeoutSeconds) * time.Second
}
return m, nil
}
// testMCPServer asks a server for its tools, with its credentials, as /mcp
// would.
func testMCPServer(ctx context.Context, m store.MCPServer) MCPTestView {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
c := &mcpclient.Client{URL: m.URL, Header: func(ctx context.Context) (http.Header, error) {
h := http.Header{}
for k, v := range m.Headers {
h.Set(k, v)
}
for k, ref := range m.HeaderRefs {
v, err := secrets.ReadString(ctx, ref)
if err != nil {
return nil, err
}
h.Set(k, v)
}
return h, nil
}}
tools, err := c.Tools(ctx)
if err != nil {
return MCPTestView{Error: err.Error()}
}
v := MCPTestView{OK: true}
for _, t := range tools {
v.Tools = append(v.Tools, m.Name+"."+t.Name)
}
sort.Strings(v.Tools)
return v
}
+107
View File
@@ -0,0 +1,107 @@
package adminapi
import (
"context"
"strings"
"sync"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient/mcptest"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type memMCP struct {
mu sync.Mutex
servers map[string]store.MCPServer
secrets map[string]string
}
func (m *memMCP) MCPServers(context.Context) ([]store.MCPServer, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []store.MCPServer
for _, s := range m.servers {
out = append(out, s)
}
return out, nil
}
func (m *memMCP) MCPServer(_ context.Context, name string) (store.MCPServer, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.servers[name]
if !ok {
return s, store.ErrNotFound
}
return s, nil
}
func (m *memMCP) PutMCPServer(_ context.Context, s store.MCPServer) error {
m.mu.Lock()
defer m.mu.Unlock()
m.servers[s.Name] = s
return nil
}
func (m *memMCP) DeleteMCPServer(_ context.Context, name string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.servers[name]; !ok {
return store.ErrNotFound
}
delete(m.servers, name)
return nil
}
func (m *memMCP) SetClusterSecret(_ context.Context, name, value string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.secrets[name] = value
return nil
}
func TestAnMCPServerIsRegisteredWithItsTokenKeptAsASecretAndTried(t *testing.T) {
srv := mcptest.New()
defer srv.Close()
a, _ := setup(t, testModels())
st := &memMCP{servers: map[string]store.MCPServer{}, secrets: map[string]string{}}
a.SetMCP(st)
secrets.SetStore(func(_ context.Context, name string) (string, error) {
st.mu.Lock()
defer st.mu.Unlock()
return st.secrets[name], nil
})
t.Cleanup(func() { secrets.SetStore(nil) })
var v MCPServerView
body := `{"url":"` + srv.URL + `","description":"tickets","headers":{"X-Org":"acme"},"secrets":{"Authorization":"Bearer t0ken"}}`
if code := call(t, a, "PUT", "/api/v1/mcp/servers/jira", body, &v); code != 200 {
t.Fatalf("add: %d", code)
}
// The token is in the store, named by reference; the row has none.
if st.secrets["mcp/jira/Authorization"] != "Bearer t0ken" || v.HeaderRefs["Authorization"] != "store:mcp/jira/Authorization" ||
strings.Contains(v.Headers["Authorization"], "t0ken") || !v.Enabled {
t.Errorf("view %+v, secrets %v", v, st.secrets)
}
var tv MCPTestView
if code := call(t, a, "POST", "/api/v1/mcp/servers/jira/test", "", &tv); code != 200 || !tv.OK || len(tv.Tools) != 3 || tv.Tools[1] != "jira.echo" {
t.Errorf("test: %d %+v", code, tv)
}
if code := call(t, a, "PUT", "/api/v1/mcp/servers/Bad.Name", body, nil); code != 400 {
t.Errorf("a name with a dot: %d", code)
}
if code := call(t, a, "PUT", "/api/v1/mcp/servers/x", `{"url":"ftp://x"}`, nil); code != 400 {
t.Errorf("not an http address: %d", code)
}
// Switched off, kept; then removed.
if code := call(t, a, "PUT", "/api/v1/mcp/servers/jira", `{"enabled":false}`, &v); code != 200 || v.Enabled || v.URL != srv.URL {
t.Errorf("off: %d %+v", code, v)
}
if code := call(t, a, "DELETE", "/api/v1/mcp/servers/jira", "", nil); code != 200 {
t.Errorf("remove: %d", code)
}
if code := call(t, a, "DELETE", "/api/v1/mcp/servers/jira", "", nil); code != 404 {
t.Errorf("remove again: %d", code)
}
}
+11
View File
@@ -68,6 +68,7 @@ var PathParams = map[string]string{
"presets/{name}": "The preset's name, as `llmgw ctl model presets` lists it.",
"nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
"secrets/{name}": "ldap-bind, oidc-client-secret or scim-token.",
"servers/{name}": "The MCP server's name: its tools are NAME.tool in /mcp.",
"jobs/{id}": "The job's ID, as starting it answered.",
"runs/{id}": "The quality run's ID, as starting it answered.",
"requests/{id}": "The request's ID: the x-request-id a client was answered with.",
@@ -406,6 +407,16 @@ var Routes = []Route{
{Method: "PUT", Path: "/api/v1/auth/secrets/{name}", Summary: "Keep ldap-bind, oidc-client-secret or scim-token in the store, for settings to name as store:auth/NAME",
Request: SecretRequest{}, Response: map[string]string{}},
// The company's own MCP servers, served through /mcp.
{Method: "GET", Path: "/api/v1/mcp/servers", Summary: "The company's MCP servers /mcp serves, and how each is reached",
Response: []MCPServerView{}},
{Method: "PUT", Path: "/api/v1/mcp/servers/{name}", Summary: "Add an MCP server, or change one: its address, headers and the secrets they carry",
Request: MCPServerRequest{}, Response: MCPServerView{}},
{Method: "DELETE", Path: "/api/v1/mcp/servers/{name}", Summary: "Remove an MCP server: its tools leave /mcp",
Response: map[string]string{}},
{Method: "POST", Path: "/api/v1/mcp/servers/{name}/test", Summary: "Ask an MCP server for its tools, with its credentials, as /mcp would",
Response: MCPTestView{}},
// Usage, requests and the record of changes.
{Method: "GET", Path: "/api/v1/usage", Summary: "What each group of the tenancy tree spent",
Query: []Param{
-8
View File
@@ -10,7 +10,6 @@ import (
"log/slog"
"maps"
"net/http"
"regexp"
"slices"
"strings"
"sync"
@@ -30,13 +29,6 @@ import (
// calls them with the server's credentials, which never leave the control
// plane. Every call is in the audit log.
// mcpServerName is what a server may be called: its tools are name.tool, so
// no dot, and short enough to read in a tool list.
var mcpServerName = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)
// ValidMCPServerName reports whether a name can be a server's.
func ValidMCPServerName(name string) bool { return mcpServerName.MatchString(name) }
const (
// mcpServersFresh is how long the server list read from the store is
// used before it is read again: a server added on one control plane is
+8
View File
@@ -5,9 +5,17 @@ import (
"database/sql"
"encoding/json"
"errors"
"regexp"
"time"
)
// mcpServerName is what a server may be called: its tools are name.tool, so
// no dot, and short enough to read in a tool list.
var mcpServerName = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)
// ValidMCPServerName reports whether a name can be a server's.
func ValidMCPServerName(name string) bool { return mcpServerName.MatchString(name) }
// MCPServer is one of the company's MCP servers, served through llmgw's
// /mcp under its name: its tools are name.tool there.
type MCPServer struct {