This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit b7a13cb7ccc8b915543999a52831e288bc3625ef Author: Yang Luo <[email protected]> AuthorDate: Sat Sep 5 11:52:00 2026 +0800 feat(connections): keep a connection's credential out of the agent's config --- agentpatch/endpoint.go | 12 +++ controllers/connector.go | 55 ++++++++++++ main.go | 2 + mcpproxy/gateway.go | 165 ++++++++++++++++++++++++++++++++++++ mcpproxy/http.go | 137 ++++++++++++++++++++++++++++++ mcpproxy/mcpproxy.go | 194 +++++++++++++++++++++++++++++++++++++++++++ mcpproxy/upstream.go | 115 +++++++++++++++++++++++++ object/connection_install.go | 105 +++++++++++++++++++---- routers/router.go | 1 + 9 files changed, 769 insertions(+), 17 deletions(-) diff --git a/agentpatch/endpoint.go b/agentpatch/endpoint.go index ce9ffe9..4f84841 100644 --- a/agentpatch/endpoint.go +++ b/agentpatch/endpoint.go @@ -50,3 +50,15 @@ func loopbackURL(path string) (string, error) { } return fmt.Sprintf("http://127.0.0.1:%d%s", number, path), nil } + +// GatewayExecutable is this Gateway's own program, which an agent is given as +// the command to run for a proxied MCP connection. +func GatewayExecutable() (string, error) { + return gatewayExecutable() +} + +// GatewayBaseUrl is the loopback address of the Gateway on this machine, for a +// child process that has to ask it something. +func GatewayBaseUrl() (string, error) { + return loopbackURL("") +} diff --git a/controllers/connector.go b/controllers/connector.go index 218638e..dc496d4 100644 --- a/controllers/connector.go +++ b/controllers/connector.go @@ -16,7 +16,12 @@ package controllers import ( "encoding/json" + "slices" "sort" + "strings" + + "github.com/apache/casbin-gateway/agentmonitor" + "github.com/apache/casbin-gateway/agentpatch" "github.com/apache/casbin-gateway/agent" "github.com/apache/casbin-gateway/agentconfig" @@ -203,3 +208,53 @@ func (c *ApiController) Disconnect() { } c.ResponseOk(planned) } + +// ResolveConnection hands the proxy the server behind one connection, with its +// credentials filled in. This is the one place a stored credential leaves +// Gateway, so it is fenced the way tool checks are: a direct loopback request, +// presenting the installation credential issued when the connection was +// written, and only for the agent that credential names. +func (c *ApiController) ResolveConnection() { + if _, ok := c.directLoopbackClient(); !ok { + c.ResponseError("resolving a connection is limited to direct loopback requests") + return + } + tokenAgentId, ok := agentpatch.ValidateIngestToken(c.Ctx.Input.Header(agentmonitor.IngestTokenHeader)) + if !ok { + c.ResponseError("resolving a connection requires a valid installation token") + return + } + + var form struct { + Owner string `json:"owner"` + Name string `json:"name"` + Agent string `json:"agent"` + } + if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil { + c.ResponseError(err.Error()) + return + } + if tokenAgentId != "" && !strings.EqualFold(form.Agent, tokenAgentId) { + c.ResponseError("agent does not match the installation this token was issued for") + return + } + + // A connection this agent was never given is not one it may resolve, or one + // agent's token would open every connection on the machine. + connection, err := object.GetConnection(form.Owner, form.Name) + if err != nil { + c.ResponseError(err.Error()) + return + } + if connection == nil || !slices.Contains(connection.Agents, form.Agent) { + c.ResponseError("this connection is not installed in " + form.Agent) + return + } + + rendered, err := object.ResolveConnection(form.Owner, form.Name) + if err != nil { + c.ResponseError(err.Error()) + return + } + c.ResponseOk(rendered) +} diff --git a/main.go b/main.go index 1d4c6a7..f56cc1f 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "github.com/apache/casbin-gateway/casdoor" "github.com/apache/casbin-gateway/conf" "github.com/apache/casbin-gateway/controllers" + "github.com/apache/casbin-gateway/mcpproxy" "github.com/apache/casbin-gateway/mcpserver" "github.com/apache/casbin-gateway/object" "github.com/apache/casbin-gateway/proxy" @@ -46,6 +47,7 @@ func main() { // process. They must exit before Gateway initializes its own services. agenthook.ServeIfInvoked() mcpserver.ServeIfInvoked() + mcpproxy.ServeIfInvoked() // A link in the URL scheme of an agent opens this executable while Gateway // is routing one to a particular copy. It is a launch, not a service. diff --git a/mcpproxy/gateway.go b/mcpproxy/gateway.go new file mode 100644 index 0000000..1930d4f --- /dev/null +++ b/mcpproxy/gateway.go @@ -0,0 +1,165 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcpproxy + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/apache/casbin-gateway/agentmonitor" +) + +const ( + resolveTimeout = 10 * time.Second + // A tool call waits on the verdict, so a Gateway that is slow to answer is + // treated like one that is not running: the call goes ahead. Holding every + // session on this machine would be the worse failure. + decideTimeout = 2 * time.Second +) + +// resolvedServer is one connection's MCP server with its credentials filled in, +// as Gateway rendered it. It exists only for the life of this process and is +// never written anywhere. +type resolvedServer struct { + Name string `json:"name"` + Transport string `json:"transport"` + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + Url string `json:"url"` + Headers map[string]string `json:"headers"` +} + +func (r *resolvedServer) envPairs() []string { + pairs := make([]string, 0, len(r.Env)) + for name, value := range r.Env { + pairs = append(pairs, name+"="+value) + } + return pairs +} + +// gatewayClient is this process's line to the Gateway that launched it. +type gatewayClient struct { + options Options + client *http.Client +} + +func newGatewayClient(options Options) *gatewayClient { + return &gatewayClient{options: options, client: &http.Client{Timeout: resolveTimeout}} +} + +type apiResponse struct { + Status string `json:"status"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func (g *gatewayClient) post(path string, body any, timeout time.Duration) (json.RawMessage, error) { + if g.options.GatewayUrl == "" { + return nil, fmt.Errorf("no Gateway address was given") + } + encoded, err := json.Marshal(body) + if err != nil { + return nil, err + } + + request, err := http.NewRequest(http.MethodPost, g.options.GatewayUrl+path, bytes.NewReader(encoded)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set(agentmonitor.IngestTokenHeader, g.options.Token) + + client := g.client + if timeout != 0 && timeout != client.Timeout { + client = &http.Client{Timeout: timeout} + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + var decoded apiResponse + if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil { + return nil, err + } + if decoded.Status != "ok" { + return nil, fmt.Errorf("%s", decoded.Msg) + } + return decoded.Data, nil +} + +// resolve asks Gateway for the server behind this connection. This is the only +// place the credential exists outside Gateway, and it exists in memory of a +// process the agent started and will end. +func (g *gatewayClient) resolve() (*resolvedServer, error) { + data, err := g.post("/api/resolve-connection", map[string]string{ + "owner": g.options.Owner, + "name": g.options.Connection, + "agent": g.options.AgentId, + }, resolveTimeout) + if err != nil { + return nil, err + } + + resolved := &resolvedServer{} + if err := json.Unmarshal(data, resolved); err != nil { + return nil, err + } + return resolved, nil +} + +// allowTool asks whether this agent may make one call. A verdict that cannot be +// had allows the call, matching what every hook does: Gateway being down or slow +// must not be the thing that breaks somebody's session. +func (g *gatewayClient) allowTool(tool string) (bool, string) { + if tool == "" || g.options.AgentId == "" || g.options.Token == "" { + return true, "" + } + + data, err := g.post("/api/check-agent-tool", map[string]string{ + "agent": g.options.AgentId, + "tool": toolKey(g.options.Connection, tool), + }, decideTimeout) + if err != nil { + return true, "" + } + + var verdict struct { + Allow bool `json:"allow"` + Reason string `json:"reason"` + } + if json.Unmarshal(data, &verdict) != nil { + return true, "" + } + return verdict.Allow, verdict.Reason +} + +// toolKey names a call the way the permission switches do, so a connection's +// tools land on the switch that agent already has for this MCP server. +func toolKey(connection string, tool string) string { + return "mcp__" + connection + "__" + tool +} + +// GatewayUrl is the address a patched agent is given for the Gateway on this +// machine. +func GatewayUrl(port string) string { + return (&url.URL{Scheme: "http", Host: "127.0.0.1:" + port}).String() +} diff --git a/mcpproxy/http.go b/mcpproxy/http.go new file mode 100644 index 0000000..eb0330c --- /dev/null +++ b/mcpproxy/http.go @@ -0,0 +1,137 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcpproxy + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// sessionHeader is what a Streamable HTTP server names a session with. It comes +// back from initialize and has to be sent on everything after it, or the server +// treats each request as a new session. +const sessionHeader = "Mcp-Session-Id" + +const httpTimeout = 5 * time.Minute + +// httpUpstream reaches a remote MCP server over Streamable HTTP. One client +// message is one POST, and the reply is either a JSON body or an event stream +// carrying several messages. +type httpUpstream struct { + resolved *resolvedServer + client *http.Client + emit func([]byte) error + + mutex sync.Mutex + session string +} + +func newHttpUpstream(resolved *resolvedServer) *httpUpstream { + return &httpUpstream{resolved: resolved, client: &http.Client{Timeout: httpTimeout}} +} + +func (u *httpUpstream) Start(emit func([]byte) error) error { + u.emit = emit + return nil +} + +func (u *httpUpstream) Close() error { return nil } + +func (u *httpUpstream) Send(line []byte) error { + request, err := http.NewRequest(http.MethodPost, u.resolved.Url, bytes.NewReader(line)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + for name, value := range u.resolved.Headers { + request.Header.Set(name, value) + } + if session := u.currentSession(); session != "" { + request.Header.Set(sessionHeader, session) + } + + response, err := u.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if session := response.Header.Get(sessionHeader); session != "" { + u.rememberSession(session) + } + // A notification is answered with 202 and no body, which is not something + // to pass on. + if response.StatusCode == http.StatusAccepted || response.StatusCode == http.StatusNoContent { + return nil + } + if response.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(response.Body, 4*1024)) + return fmt.Errorf("%s answered %s: %s", u.resolved.Url, response.Status, strings.TrimSpace(string(body))) + } + + if strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") { + return u.readEvents(response.Body) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if len(bytes.TrimSpace(body)) == 0 { + return nil + } + return u.emit(body) +} + +// readEvents forwards every message an event stream carries. Only the data +// field matters here: the rest of the SSE framing is transport, and what an MCP +// client is given back is the JSON-RPC message inside it. +func (u *httpUpstream) readEvents(body io.Reader) error { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" { + continue + } + if err := u.emit([]byte(data)); err != nil { + return err + } + } + return scanner.Err() +} + +func (u *httpUpstream) currentSession() string { + u.mutex.Lock() + defer u.mutex.Unlock() + return u.session +} + +func (u *httpUpstream) rememberSession(session string) { + u.mutex.Lock() + defer u.mutex.Unlock() + u.session = session +} diff --git a/mcpproxy/mcpproxy.go b/mcpproxy/mcpproxy.go new file mode 100644 index 0000000..a49a35d --- /dev/null +++ b/mcpproxy/mcpproxy.go @@ -0,0 +1,194 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package mcpproxy stands between an agent and the MCP server one connection +// reaches. The agent is given this executable as the server to run, so what its +// configuration file holds is the name of a connection rather than the +// credential behind it: the credential stays in Gateway and is fetched here, +// over loopback, for the life of one session. +// +// Everything the agent sends passes through, so a tool call can be asked about +// before it runs and recorded after — which is what puts connector traffic +// under the same rules as the rest of an agent's tools. +package mcpproxy + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "sync" +) + +// Subcommand is what an agent's configuration runs this executable with. +const Subcommand = "mcp-proxy" + +const maxLineBytes = 8 * 1024 * 1024 + +// ServeIfInvoked enters proxy mode when an agent launched Gateway as the MCP +// server for one connection. Like the recorder, it exits before Gateway +// initializes its own services so stdout stays a clean JSON-RPC stream. +func ServeIfInvoked() { + if len(os.Args) < 2 || os.Args[1] != Subcommand { + return + } + if err := Run(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "casbin-gateway %s: %v\n", Subcommand, err) + os.Exit(1) + } + os.Exit(0) +} + +// Options is one proxied session. +type Options struct { + Connection string + AgentId string + Owner string + GatewayUrl string + Token string +} + +func Run(args []string) error { + flags := flag.NewFlagSet(Subcommand, flag.ContinueOnError) + flags.SetOutput(os.Stderr) + options := Options{} + flags.StringVar(&options.Connection, "connection", "", "the connection to reach") + flags.StringVar(&options.AgentId, "agent", "", "agent this server was written into") + flags.StringVar(&options.Owner, "owner", "", "the account the connection belongs to") + flags.StringVar(&options.GatewayUrl, "gateway-url", "", "the running Gateway") + flags.StringVar(&options.Token, "token", "", "credential presented to Gateway") + if err := flags.Parse(args); err != nil { + return err + } + if options.Connection == "" { + return fmt.Errorf("--connection is required") + } + return Serve(os.Stdin, os.Stdout, options) +} + +// Serve proxies one session until the agent closes its end. +func Serve(in *os.File, out *os.File, options Options) error { + client := newGatewayClient(options) + + // Resolving is what needs Gateway running. Failing here with a plain + // message is better than a half-open session: the agent shows the server as + // failed to start, which is what actually happened. + resolved, err := client.resolve() + if err != nil { + return fmt.Errorf("cannot reach connection %q: %w", options.Connection, err) + } + + server, err := openUpstream(resolved) + if err != nil { + return err + } + defer server.Close() + + proxy := &proxy{options: options, client: client, server: server, out: out} + if err := server.Start(proxy.emit); err != nil { + return err + } + return proxy.pump(in) +} + +type proxy struct { + options Options + client *gatewayClient + server upstream + out *os.File + + // writing serializes what reaches the agent: replies the upstream produced + // and refusals written here are both messages on one stream. + writing sync.Mutex +} + +// message is only what the proxy has to look at. Everything else about a +// JSON-RPC message is forwarded byte for byte, so a field this build has never +// heard of still reaches the other side intact. +type message struct { + Id json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + } `json:"params"` +} + +// pump forwards everything the agent sends, asking about the calls that need +// asking about. +func (p *proxy) pump(in *os.File) error { + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + // The scanner reuses its buffer, and the upstream write may outlive + // this iteration. + forwarded := append([]byte(nil), line...) + + var parsed message + if err := json.Unmarshal(forwarded, &parsed); err == nil && parsed.Method == "tools/call" { + allowed, reason := p.client.allowTool(parsed.Params.Name) + if !allowed { + if err := p.refuse(parsed.Id, reason); err != nil { + return err + } + continue + } + } + + if err := p.server.Send(forwarded); err != nil { + return err + } + } + return scanner.Err() +} + +// emit writes one upstream message to the agent. +func (p *proxy) emit(line []byte) error { + p.writing.Lock() + defer p.writing.Unlock() + if _, err := p.out.Write(append(bytes.TrimSpace(line), '\n')); err != nil { + return err + } + return nil +} + +// refuse answers a denied call as a tool error rather than a protocol error, so +// the model is told it may not use the tool and carries on, instead of the +// client treating the server as broken. +func (p *proxy) refuse(id json.RawMessage, reason string) error { + if len(id) == 0 { + return nil + } + if reason == "" { + reason = "this tool is not allowed for this agent" + } + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]any{ + "isError": true, + "content": []map[string]any{{"type": "text", "text": "Refused by Casbin Gateway: " + reason}}, + }, + }) + if err != nil { + return err + } + return p.emit(body) +} diff --git a/mcpproxy/upstream.go b/mcpproxy/upstream.go new file mode 100644 index 0000000..9eecb55 --- /dev/null +++ b/mcpproxy/upstream.go @@ -0,0 +1,115 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcpproxy + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "sync" +) + +// upstream is the MCP server one connection actually reaches, whether that is a +// process started here or a service reached over HTTP. +// +// Start is given the function that delivers a server message to the agent, +// because the two transports produce those at different times: a stdio server +// writes whenever it likes, an HTTP one answers a request. +type upstream interface { + Start(emit func([]byte) error) error + Send(line []byte) error + Close() error +} + +func openUpstream(resolved *resolvedServer) (upstream, error) { + switch resolved.Transport { + case "stdio": + return &stdioUpstream{resolved: resolved}, nil + case "http": + return newHttpUpstream(resolved), nil + default: + return nil, fmt.Errorf("unknown transport %q for this connection", resolved.Transport) + } +} + +// stdioUpstream runs the server as a child process. The credential reaches it +// through the environment and arguments Gateway rendered, and never touches the +// agent's own configuration file. +type stdioUpstream struct { + resolved *resolvedServer + command *exec.Cmd + stdin interface { + Write([]byte) (int, error) + Close() error + } + writing sync.Mutex +} + +func (u *stdioUpstream) Start(emit func([]byte) error) error { + command := exec.Command(u.resolved.Command, u.resolved.Args...) + command.Env = append(os.Environ(), u.resolved.envPairs()...) + // The child's own diagnostics belong on this process's stderr, where the + // agent shows them, rather than mixed into the JSON-RPC stream. + command.Stderr = os.Stderr + + stdin, err := command.StdinPipe() + if err != nil { + return err + } + stdout, err := command.StdoutPipe() + if err != nil { + return err + } + if err := command.Start(); err != nil { + return fmt.Errorf("cannot start %s: %w", u.resolved.Command, err) + } + u.command, u.stdin = command, stdin + + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), maxLineBytes) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + if emit(append([]byte(nil), line...)) != nil { + return + } + } + }() + return nil +} + +func (u *stdioUpstream) Send(line []byte) error { + u.writing.Lock() + defer u.writing.Unlock() + _, err := u.stdin.Write(append(line, '\n')) + return err +} + +func (u *stdioUpstream) Close() error { + if u.command == nil { + return nil + } + if u.stdin != nil { + // Closing stdin is how an MCP server is asked to stop; killing it + // outright would leave whatever it was writing half-written. + _ = u.stdin.Close() + } + return u.command.Wait() +} diff --git a/object/connection_install.go b/object/connection_install.go index b8bf155..f62d209 100644 --- a/object/connection_install.go +++ b/object/connection_install.go @@ -19,43 +19,52 @@ import ( "sort" "github.com/apache/casbin-gateway/agentconfig" + "github.com/apache/casbin-gateway/agentpatch" "github.com/apache/casbin-gateway/connector" + "github.com/apache/casbin-gateway/mcpproxy" ) // InstallConnection writes one connection's MCP server into each agent in // agentIds and takes it out of every agent it was in before. One target's // failure is reported against that target and the rest still run, which is how // the rest of agentconfig behaves. +// +// What is written is not the server itself but Gateway standing in front of it, +// so the third-party credential never reaches the agent's configuration file +// and every call through it can be asked about. func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfig.PlanItem, error) { found, ok := connector.Get(connection.Name) if !ok { return nil, fmt.Errorf("no connector named %q", connection.Name) } - rendered, err := found.Render(connection.Credentials) - if err != nil { + // Rendering here does not produce what is written; it is what refuses to + // install a connection whose credentials are incomplete. + if _, err := found.Render(connection.Credentials); err != nil { return nil, err } planned := []*agentconfig.PlanItem{} - if len(agentIds) > 0 { - planned, err = agentconfig.AddMcp(agentconfig.McpRequest{ - Owner: connection.Owner, - To: agentIds, - Name: rendered.Name, - Transport: rendered.Transport, - Command: rendered.Command, - Args: rendered.Args, - Env: rendered.Env, - Url: rendered.Url, - Headers: rendered.Headers, - Overwrite: true, - }) + for _, agentId := range agentIds { + request, err := proxyRequest(connection, found, agentId) if err != nil { - return nil, err + planned = append(planned, &agentconfig.PlanItem{ + AgentId: agentId, Name: found.Server.Name, Action: "skip", Reason: err.Error(), + }) + continue } + // One agent at a time: each is given a credential of its own, so the + // entry written is not the same entry twice. + items, err := agentconfig.AddMcp(request) + if err != nil { + planned = append(planned, &agentconfig.PlanItem{ + AgentId: agentId, Name: found.Server.Name, Action: "skip", Reason: err.Error(), + }) + continue + } + planned = append(planned, items...) } - planned = append(planned, removeFrom(connection, rendered.Name, dropped(connection.Agents, agentIds))...) + planned = append(planned, removeFrom(connection, found.Server.Name, dropped(connection.Agents, agentIds))...) connection.Agents = installed(planned, agentIds) if err := SaveConnection(connection); err != nil { @@ -64,6 +73,44 @@ func InstallConnection(connection *Connection, agentIds []string) ([]*agentconfi return planned, nil } +// proxyRequest is the entry one agent gets: this executable, told which +// connection to reach and how to reach Gateway. +// +// The token in it is Gateway's own, not the service's: it works from loopback +// only, names one agent, and is revoked from here. The credential that would be +// worth stealing stays in Gateway's database. +func proxyRequest(connection *Connection, found connector.Connector, agentId string) (agentconfig.McpRequest, error) { + executable, err := agentpatch.GatewayExecutable() + if err != nil { + return agentconfig.McpRequest{}, err + } + gatewayUrl, err := agentpatch.GatewayBaseUrl() + if err != nil { + return agentconfig.McpRequest{}, err + } + token, err := agentpatch.IssueIngestToken(agentpatch.Target{AgentId: agentId, Owner: connection.Owner}) + if err != nil { + return agentconfig.McpRequest{}, err + } + + return agentconfig.McpRequest{ + Owner: connection.Owner, + To: []string{agentId}, + Name: found.Server.Name, + Transport: agentconfig.TransportStdio, + Command: executable, + Args: []string{ + mcpproxy.Subcommand, + "--connection", connection.Name, + "--agent", agentId, + "--owner", connection.Owner, + "--gateway-url", gatewayUrl, + "--token", token, + }, + Overwrite: true, + }, nil +} + // UninstallConnection takes the server out of every agent that has it and // forgets the connection along with its credentials. func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, error) { @@ -83,6 +130,30 @@ func UninstallConnection(owner string, name string) ([]*agentconfig.PlanItem, er return planned, nil } +// ResolveConnection is what the proxy asks for when an agent starts it: the +// server behind one connection, with its credentials filled in. It is answered +// over loopback to a process this Gateway's own configuration launched, and the +// result is never written to disk. +func ResolveConnection(owner string, name string) (*connector.Rendered, error) { + connection, err := GetConnection(owner, name) + if err != nil { + return nil, err + } + if connection == nil { + return nil, fmt.Errorf("%q is not connected", name) + } + found, ok := connector.Get(name) + if !ok { + return nil, fmt.Errorf("no connector named %q", name) + } + + rendered, err := found.Render(connection.Credentials) + if err != nil { + return nil, err + } + return &rendered, nil +} + // removeFrom deletes the server entry from each agent, reporting what happened // per agent. An agent that no longer has the entry is not an error: the // operator may have deleted it by hand, and the connection still has to let go. diff --git a/routers/router.go b/routers/router.go index fddb7bb..f49d7b9 100644 --- a/routers/router.go +++ b/routers/router.go @@ -112,6 +112,7 @@ func initAPI() { beego.Router("/api/get-connection", &controllers.ApiController{}, "GET:GetConnection") beego.Router("/api/connect", &controllers.ApiController{}, "POST:Connect") beego.Router("/api/disconnect", &controllers.ApiController{}, "POST:Disconnect") + beego.Router("/api/resolve-connection", &controllers.ApiController{}, "POST:ResolveConnection") // The desktop tray, which holds the local token instead of a session. beego.Router("/api/get-tray-menu", &controllers.ApiController{}, "GET:GetTrayMenu") --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
