Skip to content

Commit 69f5d9a

Browse files
authored
feat(mcp): praxis mcp listing + ~/.praxis/mcp-tools.json snapshot (v0.6.0) (#2)
feat(mcp): praxis mcp listing + ~/.praxis/mcp-tools.json snapshot (v0.6.0) Adds tool discovery for AI hosts that drive the CLI: - Live: `praxis mcp` (no args) hits GET /ai-api/v1/mcp/manifest and pretty-prints, or --json for AI hosts. Authoritative, always fresh. - Snapshot: ~/.praxis/mcp-tools.json rewritten by `praxis install-skill` and `praxis refresh-skills` after catalog pull. AI hosts grep this file when they want tool names without a network call. Soft-skipped when not logged in. - Praxis meta-skill body and per-skill execution preamble both gain a Discovery section pointing at these two paths. Server side: agent-factory PR #1048 adds GET /ai-api/v1/mcp/manifest. Each registered dispatcher exposes describe() returning per-function FunctionSpec (description + ArgSpec list); names mirror the in-process MCP exactly so seeded skill content keeps working. Includes CodeRabbit fixes: Fetch handles non-positive timeouts (defaults instead of disabling deadline) and explicitly checks resp.Body.Close error, matching WriteSnapshot's strict close-error pattern.
1 parent 33d24ca commit 69f5d9a

9 files changed

Lines changed: 607 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9-
(Empty — see 0.5.0 below.)
9+
(Empty — see 0.6.0 below.)
10+
11+
## [0.6.0] — 2026-05-07
12+
13+
### Added
14+
- `praxis mcp` (no args) — list every MCP namespace and function the
15+
gateway exposes, including each function's description and arg shape.
16+
Use `--json` for AI-host-friendly output. Live fetch from
17+
`/ai-api/v1/mcp/manifest`.
18+
- `~/.praxis/mcp-tools.json` snapshot — written automatically by
19+
`praxis install-skill` and `praxis refresh-skills` after the skill
20+
catalog pull. AI hosts can grep this file for available tool names
21+
without making a network call. Soft-skipped when not logged in (live
22+
`praxis mcp` still works as a fallback).
23+
- New `internal/mcpmanifest` package — `Fetch()` and `WriteSnapshot()`
24+
helpers, separated so both `cmd/mcp.go` (live) and `cmd/skill.go`
25+
(snapshot) can reuse them.
26+
- `paths.MCPTools()` — canonical location for the snapshot.
27+
- Discovery section added to the praxis meta-skill body, teaching AI
28+
hosts to use `praxis mcp --json` (live) and the snapshot file (cached).
29+
- Skill execution preamble extended with the same discovery hint, so
30+
every server-fetched org skill inherits the rule too.
31+
32+
### Changed
33+
- `praxis refresh-skills` now also re-fetches the MCP tool manifest and
34+
rewrites the snapshot. Pre-0.6 behavior (just rewriting SKILL.md
35+
files) is preserved when not logged in.
36+
37+
### Server (agent-factory)
38+
- `GET /ai-api/v1/mcp/manifest` — new gateway route, API-key
39+
authenticated. Returns `{mcps: {<mcp>: {<fn>: {description, args}}}}`
40+
by introspecting registered dispatchers.
41+
- Each dispatcher now exposes `describe()` returning per-function
42+
`FunctionSpec` (description + `ArgSpec` list). Names mirror the
43+
in-process MCP exactly so seeded skill content works verbatim.
1044

1145
## [0.5.0] — 2026-05-07
1246

cmd/mcp.go

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/Facets-cloud/praxis-cli/internal/credentials"
1515
"github.com/Facets-cloud/praxis-cli/internal/exitcode"
16+
"github.com/Facets-cloud/praxis-cli/internal/mcpmanifest"
1617
"github.com/Facets-cloud/praxis-cli/internal/render"
1718
"github.com/spf13/cobra"
1819
)
@@ -33,26 +34,31 @@ func init() {
3334
}
3435

3536
var mcpCmd = &cobra.Command{
36-
Use: "mcp <mcp> <fn>",
37-
Short: "Invoke a server-side MCP tool function",
38-
Long: `Call an MCP tool function exposed by the Praxis server gateway.
37+
Use: "mcp [<mcp> <fn>]",
38+
Short: "List or invoke server-side MCP tool functions",
39+
Long: `Call an MCP tool function exposed by the Praxis server gateway,
40+
or — with no arguments — list every function the gateway exposes.
3941
4042
The CLI never holds AWS / kube / terraform credentials — the server
4143
resolves the org from your API key and runs the call under the
4244
org-managed integration credentials.
4345
4446
Examples:
47+
praxis mcp # list every mcp + fn
48+
praxis mcp --json # same, JSON for AI hosts
4549
praxis mcp cloud_cli list_cloud_integrations
4650
praxis mcp cloud_cli run_cloud_cli --arg integration_name=aws-prod --arg command='ec2 describe-instances --output json'
4751
echo '{"integration_name":"aws-prod","command":"ec2 describe-regions"}' | praxis mcp cloud_cli run_cloud_cli --body -`,
48-
Args: cobra.ExactArgs(2),
52+
Args: func(cmd *cobra.Command, args []string) error {
53+
if len(args) == 0 || len(args) == 2 {
54+
return nil
55+
}
56+
return fmt.Errorf("accepts either 0 args (list manifest) or 2 args (<mcp> <fn>); got %d", len(args))
57+
},
4958
RunE: func(cmd *cobra.Command, args []string) error {
5059
out := cmd.OutOrStdout()
5160
asJSON := render.UseJSON(mcpJSON, false, out)
5261

53-
mcpName := args[0]
54-
fnName := args[1]
55-
5662
active, err := credentials.ResolveActive("")
5763
if err != nil {
5864
return err
@@ -65,6 +71,14 @@ Examples:
6571
os.Exit(exitcode.Auth)
6672
}
6773

74+
// No args → list manifest.
75+
if len(args) == 0 {
76+
return runManifestList(out, asJSON, active)
77+
}
78+
79+
mcpName := args[0]
80+
fnName := args[1]
81+
6882
body, err := buildMCPBody(mcpArgs, mcpBody, cmd.InOrStdin())
6983
if err != nil {
7084
render.PrintError(out, asJSON,
@@ -180,6 +194,117 @@ func buildMCPBody(argFlags []string, bodyFlag string, stdin io.Reader) ([]byte,
180194
return json.Marshal(obj)
181195
}
182196

197+
// runManifestList fetches /v1/mcp/manifest, prints either JSON (AI host
198+
// friendly) or a human-readable grouped listing. Exits the process on
199+
// network/auth errors so the rest of the dispatch RunE doesn't run.
200+
//
201+
// HTTP-level errors come back from mcpmanifest.Fetch as a Go error rather
202+
// than (status, body) since the manifest endpoint has only one success
203+
// shape. We classify the error string to pick an exit code; if the user
204+
// wants the structured detail they can pipe `praxis mcp --json` through
205+
// jq instead.
206+
func runManifestList(out io.Writer, asJSON bool, active credentials.Active) error {
207+
raw, err := mcpmanifest.Fetch(active.Profile.URL, active.Profile.Token, mcpTimeout)
208+
if err != nil {
209+
// Auth-failure shape from Fetch: "manifest fetch returned HTTP 401: ..."
210+
errStr := err.Error()
211+
if strings.Contains(errStr, "HTTP 401") || strings.Contains(errStr, "HTTP 403") {
212+
render.PrintError(out, asJSON,
213+
errStr,
214+
"the API key may be missing or revoked; run `praxis login --profile "+active.Name+"`",
215+
exitcode.Auth)
216+
os.Exit(exitcode.Auth)
217+
}
218+
if strings.Contains(errStr, "HTTP 404") {
219+
render.PrintError(out, asJSON,
220+
errStr,
221+
"the gateway does not expose /v1/mcp/manifest — server may be older than CLI",
222+
exitcode.NoConfig)
223+
os.Exit(exitcode.NoConfig)
224+
}
225+
render.PrintError(out, asJSON,
226+
errStr,
227+
"check the deployment URL and network connectivity",
228+
exitcode.Network)
229+
os.Exit(exitcode.Network)
230+
}
231+
232+
if asJSON {
233+
_, _ = out.Write(append(bytes.TrimRight(raw, "\n"), '\n'))
234+
return nil
235+
}
236+
return printManifestPretty(out, raw)
237+
}
238+
239+
// printManifestPretty renders a grouped human listing. Tolerant of
240+
// missing/extra fields — server contract may evolve.
241+
func printManifestPretty(out io.Writer, raw []byte) error {
242+
var manifest struct {
243+
Mcps map[string]map[string]struct {
244+
Description string `json:"description"`
245+
Args []struct {
246+
Name string `json:"name"`
247+
Required bool `json:"required"`
248+
Description string `json:"description"`
249+
Type string `json:"type"`
250+
} `json:"args"`
251+
} `json:"mcps"`
252+
}
253+
if err := json.Unmarshal(raw, &manifest); err != nil {
254+
// Server returned something we can't parse — fall back to raw.
255+
fmt.Fprintln(out, prettyJSON(raw))
256+
return nil
257+
}
258+
if len(manifest.Mcps) == 0 {
259+
fmt.Fprintln(out, "(no MCPs registered on this gateway)")
260+
return nil
261+
}
262+
263+
mcpNames := make([]string, 0, len(manifest.Mcps))
264+
for name := range manifest.Mcps {
265+
mcpNames = append(mcpNames, name)
266+
}
267+
sortStrings(mcpNames)
268+
269+
for i, mcpName := range mcpNames {
270+
if i > 0 {
271+
fmt.Fprintln(out)
272+
}
273+
fmt.Fprintf(out, "%s\n", mcpName)
274+
fns := manifest.Mcps[mcpName]
275+
fnNames := make([]string, 0, len(fns))
276+
for name := range fns {
277+
fnNames = append(fnNames, name)
278+
}
279+
sortStrings(fnNames)
280+
for _, fnName := range fnNames {
281+
fn := fns[fnName]
282+
fmt.Fprintf(out, " %s\n", fnName)
283+
if fn.Description != "" {
284+
fmt.Fprintf(out, " %s\n", fn.Description)
285+
}
286+
for _, a := range fn.Args {
287+
marker := " "
288+
if a.Required {
289+
marker = "*"
290+
}
291+
fmt.Fprintf(out, " %s %s — %s\n", marker, a.Name, a.Description)
292+
}
293+
}
294+
}
295+
fmt.Fprintln(out, "\n(* = required arg)")
296+
return nil
297+
}
298+
299+
// sortStrings is a tiny dependency-free sort to keep the list deterministic.
300+
func sortStrings(s []string) {
301+
for i := 1; i < len(s); i++ {
302+
for j := i; j > 0 && s[j-1] > s[j]; j-- {
303+
s[j-1], s[j] = s[j], s[j-1]
304+
}
305+
}
306+
}
307+
183308
// callMCP is the HTTP seam — tests swap it to avoid hitting the network.
184309
var callMCP = func(baseURL, token, mcp, fn string, body []byte, timeout time.Duration) ([]byte, int, error) {
185310
if baseURL == "" {

cmd/mcp_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ package cmd
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"net/http"
78
"strings"
89
"testing"
910
"time"
1011

1112
"github.com/Facets-cloud/praxis-cli/internal/credentials"
13+
"github.com/Facets-cloud/praxis-cli/internal/mcpmanifest"
1214
)
1315

1416
func resetMcpFlags() {
@@ -167,3 +169,116 @@ func TestMcpCmd_HappyPath(t *testing.T) {
167169
t.Errorf("output missing tool result: %s", buf.String())
168170
}
169171
}
172+
173+
// ---------------------------------------------------------------------------
174+
// `praxis mcp` (no args) — list manifest
175+
// ---------------------------------------------------------------------------
176+
177+
func TestPrintManifestPretty_PopulatedManifest(t *testing.T) {
178+
manifest := []byte(`{"mcps":{"k8s_cli":{"run_k8s_cli":{"description":"run kubectl","args":[{"name":"command","required":true,"description":"the kubectl command","type":"string"},{"name":"namespace","required":false,"description":"override namespace","type":"string"}]}}}}`)
179+
var buf bytes.Buffer
180+
if err := printManifestPretty(&buf, manifest); err != nil {
181+
t.Fatalf("err = %v", err)
182+
}
183+
out := buf.String()
184+
for _, want := range []string{"k8s_cli", "run_k8s_cli", "run kubectl", "command", "namespace", "* = required arg"} {
185+
if !strings.Contains(out, want) {
186+
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
187+
}
188+
}
189+
// Required marker should appear next to `command`, not `namespace`.
190+
idxCmd := strings.Index(out, "command —")
191+
idxNs := strings.Index(out, "namespace —")
192+
if idxCmd < 0 || idxNs < 0 {
193+
t.Fatalf("missing arg lines in output:\n%s", out)
194+
}
195+
// Check the rune before "command —": should be '*'.
196+
if !strings.Contains(out[max0(idxCmd-3):idxCmd], "*") {
197+
t.Errorf("command should be marked required (* prefix); got %s", out[max0(idxCmd-3):idxCmd])
198+
}
199+
if strings.Contains(out[max0(idxNs-3):idxNs], "*") {
200+
t.Errorf("namespace should NOT be marked required")
201+
}
202+
}
203+
204+
func max0(i int) int {
205+
if i < 0 {
206+
return 0
207+
}
208+
return i
209+
}
210+
211+
func TestMcpCmd_NoArgs_JsonPassthrough(t *testing.T) {
212+
t.Setenv("HOME", t.TempDir())
213+
t.Setenv("PRAXIS_PROFILE", "")
214+
resetMcpFlags()
215+
defer resetMcpFlags()
216+
217+
if err := credentials.Put("default", credentials.Profile{
218+
URL: "https://x.test", Username: "u@x.com", Token: "sk_test_T",
219+
}); err != nil {
220+
t.Fatal(err)
221+
}
222+
223+
manifest := []byte(`{"mcps":{"cloud_cli":{}}}`)
224+
orig := mcpmanifest.Fetch
225+
mcpmanifest.Fetch = func(_, _ string, _ time.Duration) ([]byte, error) {
226+
return manifest, nil
227+
}
228+
defer func() { mcpmanifest.Fetch = orig }()
229+
230+
mcpJSON = true
231+
var buf bytes.Buffer
232+
mcpCmd.SetOut(&buf)
233+
mcpCmd.SetErr(&buf)
234+
if err := mcpCmd.RunE(mcpCmd, []string{}); err != nil {
235+
t.Fatalf("RunE err = %v", err)
236+
}
237+
if got := strings.TrimSpace(buf.String()); got != strings.TrimSpace(string(manifest)) {
238+
t.Errorf("--json output mismatch:\n got: %q\nwant: %q", got, manifest)
239+
}
240+
}
241+
242+
// ---------------------------------------------------------------------------
243+
// Args validator: 1 positional arg is invalid (must be 0 or 2)
244+
// ---------------------------------------------------------------------------
245+
246+
func TestMcpCmd_OneArg_Rejected(t *testing.T) {
247+
if err := mcpCmd.Args(mcpCmd, []string{"k8s_cli"}); err == nil {
248+
t.Fatal("expected error for 1 arg, got nil")
249+
}
250+
if err := mcpCmd.Args(mcpCmd, []string{}); err != nil {
251+
t.Errorf("0 args should be valid: %v", err)
252+
}
253+
if err := mcpCmd.Args(mcpCmd, []string{"a", "b"}); err != nil {
254+
t.Errorf("2 args should be valid: %v", err)
255+
}
256+
}
257+
258+
// ---------------------------------------------------------------------------
259+
// Pretty-printer tolerates malformed/empty manifest gracefully
260+
// ---------------------------------------------------------------------------
261+
262+
func TestPrintManifestPretty_EmptyMcps(t *testing.T) {
263+
var buf bytes.Buffer
264+
if err := printManifestPretty(&buf, []byte(`{"mcps":{}}`)); err != nil {
265+
t.Fatalf("err = %v", err)
266+
}
267+
if !strings.Contains(buf.String(), "no MCPs registered") {
268+
t.Errorf("output missing empty-state message: %s", buf.String())
269+
}
270+
}
271+
272+
func TestPrintManifestPretty_MalformedFallsBackToRaw(t *testing.T) {
273+
var buf bytes.Buffer
274+
if err := printManifestPretty(&buf, []byte("not json")); err != nil {
275+
t.Fatalf("err = %v", err)
276+
}
277+
if !strings.Contains(buf.String(), "not json") {
278+
t.Errorf("output should contain raw fallback: %s", buf.String())
279+
}
280+
}
281+
282+
// Sanity check on the testing seam — referenced once so the import isn't
283+
// flagged as unused if all the tests above are temporarily disabled.
284+
var _ = errors.New

0 commit comments

Comments
 (0)