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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,26 @@ nothing is inherited from the orcli process, on the same grounds the API key
is never read from it. A server is not started until the model calls
`mcp_list_resources` or `mcp_read_resource` naming it, and is kept running for
reuse by later calls in the same session once it is. Only resource listing
and reading are offered; a server's own tools and prompts are not surfaced,
and a remote server reached over HTTP or SSE rather than launched locally is
not supported today.
and reading are offered; a server's own tools and prompts are not surfaced.

A server is also reachable remotely over the Streamable HTTP transport
(issue #62) - the one the MCP specification currently recommends for new
integrations; the older HTTP+SSE transport is not supported. Name `url`
instead of `command`, with optional `headers` (a bearer token or any other
credential the remote server itself needs, never read from this process's
own environment, the same rule `env` follows for a local server):

```json
{"mcp":{"servers":[{"name":"watchgoose","url":"https://watchgoose.example.com/mcp","headers":{"Authorization":"Bearer your-token"}}]}}
```

A server names `command` (local) or `url` (remote), never both and never
neither - either is refused by name at configuration read time. A local and
a remote server can be configured side by side; `mcp_list_resources` and
`mcp_read_resource` work identically against either kind once a server is
named, since both speak the same JSON-RPC shape underneath. A remote
server's session (if it assigns one) is torn down with a best-effort DELETE
when the session ends, the same way a local server's subprocess is stopped.

Task operations in Notion require the integration token to be shared with the
task data source, plus explicit IDs/property names that bind task operations to
Expand Down
5 changes: 4 additions & 1 deletion cmd/orcli/ask.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,10 @@ func newToolsetWithTasks(dir, notionToken string, apiarySettings []string, integ
func newMCPManager(servers []config.MCPServer) *mcp.Manager {
set := make([]mcp.Server, 0, len(servers))
for _, s := range servers {
set = append(set, mcp.Server{Name: s.Name, Command: s.Command, Args: s.Args, Env: s.Env})
set = append(set, mcp.Server{
Name: s.Name, Command: s.Command, Args: s.Args, Env: s.Env,
URL: s.URL, Headers: s.Headers,
})
}
return mcp.NewManager(set)
}
Expand Down
24 changes: 24 additions & 0 deletions cmd/orcli/ask_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/glenjbarber/orcli/internal/config"
"github.com/glenjbarber/orcli/internal/openrouter"
"github.com/glenjbarber/orcli/internal/tools"
"github.com/glenjbarber/orcli/internal/tui"
Expand Down Expand Up @@ -695,3 +696,26 @@ func TestSilentTurnStillConfirmsTheModel(t *testing.T) {
t.Errorf("confirmed(%q, %v), want (\"some/model\", true)", gotModel, gotSilent)
}
}

// TestNewMCPManagerPassesTheRemoteServerFieldsThrough checks the adapter
// forwards URL/Headers (issue #62), not only the local Command/Args/Env
// fields config.MCPServer already carried - configuredServers() (used in
// every tool's own description) names every configured server regardless of
// kind, which is enough to prove the remote one actually reached the
// Manager rather than being dropped on the way.
func TestNewMCPManagerPassesTheRemoteServerFieldsThrough(t *testing.T) {
m := newMCPManager([]config.MCPServer{
{Name: "local-files", Command: "npx", Args: []string{"-y", "server-filesystem"}},
{Name: "watchgoose", URL: "https://watchgoose.example.com/mcp", Headers: map[string]string{"Authorization": "Bearer t"}},
})
defer m.Close()

set := m.ToolSet()
if len(set) == 0 {
t.Fatal("ToolSet() returned no tools")
}
desc := set[0].Describe().Function.Description
if !strings.Contains(desc, "local-files") || !strings.Contains(desc, "watchgoose") {
t.Fatalf("tool description = %q, want it to name both configured servers", desc)
}
}
7 changes: 5 additions & 2 deletions internal/config/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ func TestExampleConfigLoadsThroughTheRealParser(t *testing.T) {
if err != nil {
t.Fatalf("MCPServers() = %v, want nil", err)
}
if len(servers) != 1 || servers[0].Name != "example" {
t.Errorf("MCPServers() = %+v, want one server named %q", servers, "example")
if len(servers) != 2 || servers[0].Name != "example" {
t.Errorf("MCPServers() = %+v, want two servers, the first named %q", servers, "example")
}
if servers[1].Name != "remote-example" || servers[1].URL == "" || len(servers[1].Headers) == 0 {
t.Errorf("MCPServers()[1] = %+v, want the remote example with a URL and headers", servers[1])
}
}

Expand Down
68 changes: 47 additions & 21 deletions internal/config/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,39 @@ func (m MCP) MarshalJSON() ([]byte, error) {
return m.raw, nil
}

// MCPServer is one Model Context Protocol server the reader has configured,
// launched as a subprocess and spoken to over its standard input and output.
//
// Only this shape - a local command, launched over stdio - is read today. A
// remote server reached over HTTP or SSE is not a configuration this client
// understands yet.
// MCPServer is one Model Context Protocol server the reader has configured -
// either a local command launched as a subprocess and spoken to over its
// standard input and output, or a remote server reached over HTTP using the
// Streamable HTTP transport (the one the specification currently
// recommends; the older HTTP+SSE transport is not supported). The two are
// mutually exclusive: MCPServers refuses a server naming both a command and
// a URL, or neither.
type MCPServer struct {
// Name is how the model names this server when it calls a tool.
Name string

// Command is the program to launch, resolved by the search path.
// Empty for a remote server.
Command string

// Args are the arguments the program is started with.
// Args are the arguments the program is started with. Local only.
Args []string

// Env is the subprocess's environment, as KEY=VALUE pairs, in addition to
// PATH. It is never read from this process's own environment: a server
// that needs a credential gets it because the reader wrote it here, the
// same way every other credential in this file is handled.
// same way every other credential in this file is handled. Local only.
Env []string

// URL is the remote server's own Streamable HTTP endpoint. Empty for a
// local server.
URL string

// Headers are sent with every request to a remote server - this
// transport's own equivalent of Env, for a bearer token or any other
// credential the server itself needs, written here rather than read
// from this process's own environment. Remote only.
Headers map[string]string
}

// MCPServers returns the MCP servers configured under the mcp block.
Expand All @@ -68,6 +80,8 @@ func (c Config) MCPServers() ([]MCPServer, error) {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
} `json:"servers"`
}
if err := json.Unmarshal(c.MCP.raw, &block); err != nil {
Expand All @@ -79,37 +93,49 @@ func (c Config) MCPServers() ([]MCPServer, error) {
for _, s := range block.Servers {
name := strings.TrimSpace(s.Name)
command := strings.TrimSpace(s.Command)
url := strings.TrimSpace(s.URL)
if name == "" {
return nil, fmt.Errorf("config: %s: a server with no name was given", mcpKey)
}
if command == "" {
return nil, fmt.Errorf("config: %s: server %q has no command", mcpKey, name)
if command == "" && url == "" {
return nil, fmt.Errorf("config: %s: server %q has neither a command nor a url", mcpKey, name)
}
if command != "" && url != "" {
return nil, fmt.Errorf("config: %s: server %q names both a command and a url; a server is local (command) or remote (url), never both", mcpKey, name)
}
if _, exists := seen[name]; exists {
return nil, fmt.Errorf("config: %s: server %q is listed more than once", mcpKey, name)
}
seen[name] = struct{}{}

keys := make([]string, 0, len(s.Env))
for k := range s.Env {
keys = append(keys, k)
}
sort.Strings(keys)
env := make([]string, 0, len(keys))
for _, k := range keys {
env = append(env, k+"="+s.Env[k])
}

servers = append(servers, MCPServer{
Name: name,
Command: command,
Args: append([]string(nil), s.Args...),
Env: env,
Env: sortedKeyValues(s.Env),
URL: url,
Headers: s.Headers,
})
}
return servers, nil
}

// sortedKeyValues renders a map as KEY=VALUE strings, sorted by key, so the
// order is stable across reads of the same file rather than following Go's
// own randomized map iteration.
func sortedKeyValues(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]string, 0, len(keys))
for _, k := range keys {
out = append(out, k+"="+m[k])
}
return out
}

func readMCP(raw json.RawMessage) (*MCP, error) {
if isNull(raw) || len(bytes.TrimSpace(raw)) == 0 {
return nil, nil
Expand Down
59 changes: 59 additions & 0 deletions internal/config/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,65 @@ func TestMCPServersAreReadAndUnknownMembersSurvive(t *testing.T) {
}
}

// TestMCPServersReadsARemoteServer covers a server configured with a url
// instead of a command - the Streamable HTTP shape this client now supports
// (issue #62).
func TestMCPServersReadsARemoteServer(t *testing.T) {
h := home(t)
writeConfigAt(t, h, `{"api_key":"openrouter","mcp":{"servers":[{"name":"watchgoose","url":"https://watchgoose.example.com/mcp","headers":{"Authorization":"Bearer secret-token"}}]}}`)

cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
servers, err := cfg.MCPServers()
if err != nil {
t.Fatalf("MCPServers: %v", err)
}
if len(servers) != 1 {
t.Fatalf("MCPServers = %#v, want one server", servers)
}
got := servers[0]
if got.Name != "watchgoose" || got.URL != "https://watchgoose.example.com/mcp" {
t.Fatalf("MCPServers[0] = %#v", got)
}
if got.Command != "" {
t.Errorf("a remote server carried a command: %q", got.Command)
}
if got.Headers["Authorization"] != "Bearer secret-token" {
t.Errorf("MCPServers[0].Headers = %#v", got.Headers)
}
}

// TestMCPServersRejectBothCommandAndURL and
// TestMCPServersRejectNeitherCommandNorURL guard the mutual-exclusion rule:
// a server is local or remote, never both and never neither.
func TestMCPServersRejectBothCommandAndURL(t *testing.T) {
h := home(t)
writeConfigAt(t, h, `{"api_key":"openrouter","mcp":{"servers":[{"name":"files","command":"npx","url":"https://example.com/mcp"}]}}`)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
_, err = cfg.MCPServers()
if err == nil || !strings.Contains(err.Error(), "both a command and a url") {
t.Fatalf("MCPServers() = %v, want a refusal naming both a command and a url", err)
}
}

func TestMCPServersRejectNeitherCommandNorURL(t *testing.T) {
h := home(t)
writeConfigAt(t, h, `{"api_key":"openrouter","mcp":{"servers":[{"name":"files"}]}}`)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
_, err = cfg.MCPServers()
if err == nil || !strings.Contains(err.Error(), "neither a command nor a url") {
t.Fatalf("MCPServers() = %v, want a refusal naming neither", err)
}
}

func TestMCPServersMissingBlockOrEmpty(t *testing.T) {
for _, body := range []string{
`{"api_key":"openrouter"}`,
Expand Down
Loading
Loading