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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions pkgs/mcpx/docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ and costs an agent the ability to restart a server that has wedged. What they
carry instead is a description that says so and MCP annotations
(`readOnlyHint`, `destructiveHint`) for a client that wants to scope on them.

## /v1 operations (83)
## /v1 operations (85)

| Operation | Route | CLI | MCP tool | Plugin | Notes |
| --- | --- | --- | --- | --- | --- |
| `adapters_add` | `POST /v1/adapters` | `mcpx adapter add` | `mcpx_adapters_add` | `ops.adaptersAdd()` | Privileged. |
| `adapters_remove` | `DELETE /v1/adapters/{name}` | `mcpx adapter remove` | `mcpx_adapters_remove` | `ops.adaptersRemove()` | Privileged. Destructive. |
| `apis_add` | `POST /v1/apis` | `mcpx api add` | `mcpx_apis_add` | `ops.apisAdd()` | Privileged. |
| `apis_remove` | `DELETE /v1/apis/{name}` | `mcpx api remove` | `mcpx_apis_remove` | `ops.apisRemove()` | Privileged. Destructive. |
| `artifacts_list` | `GET /v1/artifacts` | `mcpx artifact list` *generated* | `mcpx_artifacts_list` | `ops.artifactsList()` | |
| `artifact_put` | `POST /v1/artifacts` | `mcpx artifact put` *generated* | `mcpx_artifact_put` | `ops.artifactPut()` | Privileged. |
| `artifact_delete` | `DELETE /v1/artifacts/{id}` | `mcpx artifact delete` *generated* | `mcpx_artifact_delete` | `ops.artifactDelete()` | Privileged. Destructive. |
Expand Down Expand Up @@ -128,7 +130,7 @@ that reaches none says why: those are decisions, not gaps.
| Command | Operations | Notes |
| --- | --- | --- |
| `adapter` | `adapters_add`, `adapters_remove` | |
| `api` | – | OpenAPI declarations are files the CLI reads; the tools they produce are served over MCP, and are not yet a tool source the daemon owns (#83). |
| `api` | `apis_add`, `apis_remove` | |
| `artifact` / `artifacts` | `artifacts_list`, `artifact_put`, `artifact_delete`, `artifact_get` | Generated from the operation table. |
| `ask` | `ask_begin`, `ask_poll`, `ask_abandon`, `ask_answers` | Generated from the operation table. |
| `batch` | `batch` | |
Expand Down
54 changes: 54 additions & 0 deletions pkgs/mcpx/internal/api/ops_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,5 +200,59 @@ func settingsOps() []Op {
Desc: "the adapter to remove, by its declared name"},
},
},
{
Name: "apis_add", Method: "POST", Path: "/v1/apis",
Permission: PermissionAdmin,
Command: "api add",
Summary: "Add an API declaration, live",
Description: "Writes the declaration to the first file paths.apis " +
"names and reloads, so the API is callable immediately and " +
"is still there after a restart. The body is the declaration " +
"itself: name, spec, and the filters that select its tools. " +
"A name another configured file already declares is refused " +
"rather than duplicated across files, and a replacement edits " +
"the file that defines it. Refused, with the remedy, when " +
"paths.apis names no file, because a declaration written " +
"somewhere nothing reads would report success and change " +
"nothing.",
Admin: true, Mutating: true,
Params: []Param{
{Name: "name", In: InBody, Type: "string", Required: true,
Desc: "the API's name, which is also its namespace"},
{Name: "spec", In: InBody, Type: "string", Required: true,
Desc: "the OpenAPI document: a path or a URL"},
{Name: "baseUrl", In: InBody, Type: "string",
Desc: "override the server URL the document declares"},
{Name: "include", In: InBody, Type: "array", Desc: "only operations matching these words, comma separated or repeated",
Schema: `{"type":"array","items":{"type":"string"}}`},
{Name: "exclude", In: InBody, Type: "array", Desc: "drop operations matching these words",
Schema: `{"type":"array","items":{"type":"string"}}`},
{Name: "methods", In: InBody, Type: "array", Desc: "HTTP methods to expose; 'all' for every one (default: read-only)",
Schema: `{"type":"array","items":{"type":"string"}}`},
{Name: "includeDeprecated", In: InBody, Type: "boolean",
Desc: "include operations the document marks deprecated"},
{Name: "headers", In: InBody, Type: "object", Desc: "headers sent with every operation call; values may reference ${VAR} environment variables",
Schema: `{"type":"object","additionalProperties":{"type":"string"}}`},
{Name: "description", In: InBody, Type: "string",
Desc: "one line shown beside the namespace"},
{Name: "replace", In: InBody, Type: "boolean",
Desc: "overwrite a declaration of the same name instead of refusing"},
},
},
{
Name: "apis_remove", Method: "DELETE", Path: "/v1/apis/{name}",
Permission: PermissionAdmin,
Idempotent: true,
Command: "api remove",
Summary: "Remove an API declaration, live",
Description: "Deletes the declaration from whichever configured " +
"apis file defines it and reloads. Instances of that " +
"API stop; a caller mid-call against it fails.",
Admin: true, Mutating: true, Destructive: true,
Params: []Param{
{Name: "name", In: InPath, Type: "string", Required: true,
Desc: "the API to remove, by its declared name"},
},
},
}
}
31 changes: 31 additions & 0 deletions pkgs/mcpx/internal/cli/api_add_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cli

import (
"strings"
"testing"
)

// The advertised form puts flags after the name: mcpx api add petstore
// --spec ./petstore.json. Hoisting must keep each flag with its value,
// or the parser reads the spec path as the name.
func TestAPIHoistKeepsSpecPairedWithItsValue(t *testing.T) {
in := []string{"petstore", "--spec", "./petstore.json", "--include", "pets,store"}
got := hoistFlags(in, apiAddValuedFlags)
want := []string{"--spec", "./petstore.json", "--include", "pets,store", "petstore"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("hoisted %v, want %v", got, want)
}
}

func TestParseAPIHeaderSplitsKeyAndValue(t *testing.T) {
k, v, err := parseAPIHeader("Authorization=Bearer abc==")
if err != nil || k != "Authorization" || v != "Bearer abc==" {
t.Fatalf("parseAPIHeader = %q, %q, %v", k, v, err)
}
if _, _, err := parseAPIHeader("no-equals-here"); err == nil {
t.Fatal("a header without = was accepted")
}
if _, _, err := parseAPIHeader("=empty-key"); err == nil {
t.Fatal("a header with an empty key was accepted")
}
}
3 changes: 3 additions & 0 deletions pkgs/mcpx/internal/cli/flagindex_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions pkgs/mcpx/internal/cli/manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -664,14 +664,17 @@ func handCommands() []Command {
},
{
Name: "api", Group: "configuration",
Local: "OpenAPI declarations are files the CLI reads; the tools they produce are served over MCP, and are not yet a tool source the daemon owns (#83).",
Usage: "[list|tools|call] [--spec <path-or-url>] [<tool> '<json>']",
Usage: "[list|tools|call|add|remove] [--spec <path-or-url>] [<tool> '<json>' | <name>]",
Summary: "HTTP services described by OpenAPI, as tools",
Detail: "Declarations named by paths.apis, or one given with --spec, are " +
"turned into a tool per operation: read-only methods by default, " +
"every method with --methods all.",
"every method with --methods all. add and remove go through the " +
"daemon (/v1/apis): the declaration is written to a configured " +
"file and reloaded, so the change is live at once and survives " +
"a restart.",
Examples: []string{
"mcpx api tools --spec https://example.com/openapi.json",
"mcpx api add petstore --spec ./petstore.json",
},
},
}
Expand Down
136 changes: 135 additions & 1 deletion pkgs/mcpx/internal/cli/openapi_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"

Expand Down Expand Up @@ -85,6 +86,15 @@ func (a *App) CmdAPI(ctx context.Context, args []string) error {
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
sub, args = args[0], args[1:]
}
// add and remove go through the daemon, like adapter add/remove: the
// write has to land where the running daemon reads, and the local
// declaration files are not the authority for a live change.
if sub == "add" {
return a.apiAdd(ctx, args)
}
if sub == "remove" {
return a.apiRemove(ctx, args)
}
args = hoistFlags(args, map[string]bool{
"spec": true, "base-url": true, "name": true,
"include": true, "exclude": true, "methods": true, "limit": true,
Expand Down Expand Up @@ -234,7 +244,7 @@ func (a *App) CmdAPI(ctx context.Context, args []string) error {
}
return fmt.Errorf("no api named %q", fs.Arg(0))
}
return fmt.Errorf("no api subcommand %q; list, tools, call or serve", sub)
return fmt.Errorf("no api subcommand %q; list, tools, call, serve, add or remove", sub)
}

// apiTools exposes declared APIs through the MCP server.
Expand Down Expand Up @@ -293,3 +303,127 @@ func splitCSV(v string) []string {
}
return out
}

// apiAddValuedFlags are api add's flags that take a value, for
// hoistFlags: the name comes first on the command line, so flags and
// their values move ahead of it as pairs.
var apiAddValuedFlags = map[string]bool{
"spec": true, "base-url": true, "include": true, "exclude": true,
"methods": true, "description": true, "header": true,
}

// parseAPIHeader splits one --header KEY=VALUE flag.
func parseAPIHeader(h string) (string, string, error) {
k, v, ok := strings.Cut(h, "=")
if !ok || strings.TrimSpace(k) == "" {
return "", "", fmt.Errorf("a header needs KEY=VALUE, got %q", h)
}
return k, v, nil
}

// apiAdd declares a new API through the daemon, the way adapter add
// does: the write lands in the declaration file the running daemon
// reads and the reload makes it callable at once.
func (a *App) apiAdd(ctx context.Context, args []string) error {
args = hoistFlags(args, apiAddValuedFlags)
fs := newFlagSet("api")
spec := fs.String("spec", "", "the OpenAPI document: a path or a URL")
baseURL := fs.String("base-url", "", "override the server URL the document declares")
include := fs.String("include", "", "only operations matching these words, comma separated")
exclude := fs.String("exclude", "", "drop operations matching these words")
methods := fs.String("methods", "", "HTTP methods to expose; 'all' for every one (default: read-only)")
deprecated := fs.Bool("deprecated", false, "include operations the document marks deprecated")
desc := fs.String("description", "", "one line shown beside the namespace")
header := newRepeated()
fs.Var(header, "header", "a header sent with every operation call, as KEY=VALUE (repeatable)")
replace := fs.Bool("replace", false, "overwrite a declaration of the same name")
if err := parseFlags(a, fs, args); err != nil {
return err
}
if fs.NArg() == 0 {
return fmt.Errorf("usage: mcpx api add <name> --spec <path-or-url> [--base-url <url>] [--include a,b] [--exclude c] [--methods all] [--deprecated] [--header K=V]... [--description ...] [--replace]")
}
if strings.TrimSpace(*spec) == "" {
return fmt.Errorf("an API needs --spec <path-or-url>")
}
name := fs.Arg(0)
body := map[string]any{
"name": name, "spec": *spec, "replace": *replace,
Comment on lines +350 to +351

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve local spec paths before sending them to the daemon

When the CLI runs from a directory different from the running daemon’s working directory, mcpx api add petstore --spec ./petstore.json persists the caller-relative path unchanged. The generated API server has no explicit working directory, so its default global instance inherits the daemon’s directory, and openapi.Load reads the relative path directly. Consequently, add reports success but the API either cannot load its document or loads an unrelated document at the same relative path. Resolve local spec paths against the CLI’s working directory before constructing the request, while preserving URL handling.

Useful? React with 👍 / 👎.

}
if *baseURL != "" {
body["baseUrl"] = *baseURL
}
if v := splitCSV(*include); len(v) > 0 {
body["include"] = v
}
if v := splitCSV(*exclude); len(v) > 0 {
body["exclude"] = v
}
if v := splitCSV(*methods); len(v) > 0 {
body["methods"] = v
}
if *deprecated {
body["includeDeprecated"] = true
}
if *desc != "" {
body["description"] = *desc
}
if len(header.values) > 0 {
headers := map[string]string{}
for _, h := range header.values {
k, v, err := parseAPIHeader(h)
if err != nil {
return err
}
headers[k] = v
}
body["headers"] = headers
}
c, err := a.ensure(ctx)
if err != nil {
return err
}
raw, err := c.Do(ctx, http.MethodPost, "/v1/apis", mustMarshal(body))
if err != nil {
return err
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return err
}
if a.JSON {
return a.out(out)
}
fmt.Printf("added api %s to %v; it is live now\n", name, out["written"])
return nil
}

// apiRemove removes a declaration through the daemon, mirroring adapter
// remove: the file that defines it is the one edited.
func (a *App) apiRemove(ctx context.Context, args []string) error {
fs := newFlagSet("api")
if err := parseFlags(a, fs, args); err != nil {
return err
}
if fs.NArg() == 0 {
return fmt.Errorf("usage: mcpx api remove <name>")
}
name := fs.Arg(0)
c, err := a.ensure(ctx)
if err != nil {
return err
}
raw, err := c.Do(ctx, http.MethodDelete, "/v1/apis/"+urlEscape(name), nil)
if err != nil {
return err
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return err
}
if a.JSON {
return a.out(out)
}
fmt.Printf("removed api %s from %v; it is gone from the running daemon\n", name, out["written"])
return nil
}
Loading
Loading