Company MCP servers are registered with ctl mcp, and tools granted with keys limits --tools
CI / test (push) Failing after 5m4s
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:
1 parent
ca77637a71
commit
4453f469e1
16 files changed
+893
-9
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }})
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user