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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Run linters
uses: golangci/golangci-lint-action@v7
with:
version: v2.11
version: v2.13.2
args: --timeout=3m
# goreleaser runs `go mod tidy` before building. If that changes anything,
# the release build's tree is dirty and Go stamps the version "+dirty".
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **MCP gateway dry-run safety.** `mcp gateway call` now rejects `--dry-run`
before it creates a gateway client or sends a request: c1i cannot safely
preview or suppress a tool's side effects.
- **MCP gateway compound-host routing.** Default gateway URL derivation now
preserves a compound routing suffix, so `mcp gateway list-tools` and `call`
reach the live gateway without requiring `--gateway-url`.
- **Current Go toolchain and runtime dependencies.** Release builds now use Go
1.27.1; the lint gate uses golangci-lint 2.13.2, which supports that
toolchain. Updated OAuth, terminal, JOSE, and Go runtime dependencies are
included in the release.
- **Agent runbook discovery.** `docs guide` now lists a concise purpose beside
every embedded workflow, `docs agents` provides a task-to-guide index, and
shell completion offers guide names with the same summaries. The README now
shows the PowerShell loading command. Checks ensure every guide has a summary
and every embedded cross-reference resolves.
- **C1.ai branding.** Documentation now uses C1.ai for the product and its
corporate website; technical GitHub, package, distribution, and endpoint
references retain their existing hosts.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1266,6 +1266,12 @@ c1i completion zsh > "${fpath[1]}/_c1i"
c1i completion fish > ~/.config/fish/completions/c1i.fish
```


```powershell
# PowerShell (load in the current session)
c1i completion powershell | Out-String | Invoke-Expression
```

`powershell` is also available. Each generator takes `--no-descriptions` to
emit a script that completes names only, without the per-command help text.

Expand Down
70 changes: 26 additions & 44 deletions cmd/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,16 @@ authenticating. Which one depends on what you are after:
endpoints --filter <text>` lists matching routes and has a real no-match — an
empty result means nothing matched, not "the search gave up" — then `docs
endpoint <path>` prints that route's full request/response schema.
- **A step-by-step runbook** (register an MCP server, configure a new app,
request access): `docs guide` lists the embedded guides and `docs guide
<name>` prints one. These are static content, no network call.
- **A step-by-step runbook**: `docs guide` lists the embedded guides with a
one-line purpose, and `docs guide <name>` prints one. These are static
content, no network call. The complete workflow index is:

| Goal | Guide |
|---|---|
| Register, distribute, or test MCP tools | `register-mcp-server` → `assign-toolset-everyone` → `test-mcp-gateway` |
| Create a manually managed app | `configure-new-app` |
| Request or approve access | `request-access` → `inspect-and-approve-task` |
| Configure delegated provisioning | `delegate-entitlement-provisioning` |
- **The raw OpenAPI spec**: `docs openapi` (cached 24h locally).

Rule of thumb: a product concept starts at `docs search` → `docs page`; a raw
Expand Down Expand Up @@ -277,26 +284,15 @@ List commands auto-paginate to completion by default — one invocation gets
every page. Pass `--page-token` to opt out and fetch a single page manually.
Don't write your own pagination loop.

`--page-size` is a request, not a promise: a page may come back with more
rows than you asked for. That is server behavior, not a c1i bug. How much
more varies per endpoint and per size, so treat any figure you measure as
true of that endpoint, at that size, today.

Three more traps in the same flag:
`--page-size` is a request, not a promise: a page can come back with more
rows. Some endpoints won't return fewer than their server-side floor, but that
is not universal. `--page-size 0` uses the server's default of 25. A value
above the maximum is not an error; c1i clamps it. A negative page size or
limit rejects it before sending.

- Most endpoints won't return fewer than 5 rows however small a positive
value you pass, but that is not universal: `policies list` floors at 6,
and `mcp servers catalog list` has no floor.
- `--page-size 0` does not mean "no paging". The server substitutes its own
default of 25, and the rows returned may then overshoot that.
- A value above the max is not an error: c1i clamps it and sends the max. A
negative `--page-size` or `--limit` is a usage error — c1i rejects it
before sending, at exit 2.

So never size a batch, count a result set, or infer "there are only N of
these" from `--page-size`. `--limit N` is the exact control: c1i enforces
it client-side, so it holds whatever the server returns, and it stops
auto-pagination once reached.
`--limit N` is the exact control: c1i enforces it client-side, whatever size
the server returns.

## Before you mutate

Expand Down Expand Up @@ -342,21 +338,11 @@ resource with `--resource-id` likewise means you drop
each id is still its own request, but don't loop the CLI per id — pass them
all at once (pipe `mcp tools search --state pending --fields id | jq -r .id`).
- Owner and grant provisioning are asynchronous. A read immediately after a
write can look like a silent no-op for a couple of minutes (owner writes
observed at 45-150s across set-owners, add-owner, remove-owner and the
owner "apps create" assigns; grants: up to a couple of minutes). Verify
owners with `c1i apps owners <app-id>`, not `apps get`'s `appOwners`
field, and don't wait for that field to fill — it read [] on every app
checked, including all those `apps owners` reported
owners for. An empty `appOwners` is not evidence an app has no owners.
`apps owners` also
returns zero rows at exit 0 for a well-formed but nonexistent app id, so an
empty result is either "no owners" or "wrong id"; `apps add-owner` on the
same id exits 4. Don't write your own poll loop for this: `apps set-owners`
takes `--wait` (with `--wait-timeout`, default `4m`) and blocks until every
requested owner appears. A `--wait` timeout exits `1` and does not mean the
write failed — provisioning may still be in flight, so re-check with
`apps owners` instead of re-issuing the write.
write can look like a no-op. Verify app owners with `c1i apps owners
<app-id>`, not `apps get`'s `appOwners` field; an empty `appOwners` is not
evidence an app has no owners. Use `apps set-owners --wait` when the write
must converge before the next step. A wait timeout does not mean the write
failed — re-check before writing again.
- `grants list --wait` can report success with zero rows. An empty result is
stable, so a filter matching nothing settles in ~10s and exits `0` -- which
looks identical to "the grant did not happen" but usually means "not yet".
Expand All @@ -382,14 +368,10 @@ resource with `--resource-id` likewise means you drop
`callFunction.functionId`, same as `accounts list --unmapped-only` above.
With `--page-token` a page can come back with zero rows while a matching
automation exists on another page.
- Same rule, worse case: a `--fields`/`C1I_FIELDS` spec that matches nothing
anywhere, combined with `--limit`, scans the whole collection before
erroring exit `2` — like `--unmapped-only` above, a post-fetch filter can't
bound the work when nothing has matched yet. A typo is the ordinary way to
hit this. Measured: `tasks list --fields <typo> --limit 2` made 193
requests over ~41s on a ~9,650-row tenant; a 35,000-row `entitlements list`
would take minutes. No cap exists for this on purpose — a first-page-only
check would false-error on a real field that's just sparse.
- A `--fields`/`C1I_FIELDS` projection that matches nothing scans until the
command can prove no row contains that field, even when `--limit` is set.
Correct the field name instead of treating a long-running command as an API
failure.
- A task's `outcome` field is omitted while it's unspecified, not while the
task is open — a task can be `TASK_STATE_OPEN` and already carry a real,
non-UNSPECIFIED outcome (e.g. a provisioning failure mid-flow). Use
Expand Down
44 changes: 35 additions & 9 deletions cmd/docs_guide.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,13 @@ tasks:

## 6. Verify

c1i grants list --app-id "$APP_ID" --entitlement-id "$ENTITLEMENT_ID"
For each user ID from step 4, wait for that user's grant:

c1i grants list --app-id "$APP_ID" --entitlement-id "$ENTITLEMENT_ID" \
--user-id "$USER_ID" --wait --wait-min 1

Next, verify the caller-facing result with "c1i docs guide test-mcp-gateway".

Grants are eventually consistent — a just-created grant can take up to a
minute or two to appear in this list.
`

// guideTestMCPGateway verifies, with c1i, the pieces that must be in place
Expand Down Expand Up @@ -776,6 +779,18 @@ checking once immediately.
what's actually governing a given task.
`

// guideSummaries makes the no-argument guide listing useful for discovering
// the right workflow without loading every runbook.
var guideSummaries = map[string]string{
"register-mcp-server": "Register a hosted or external MCP server and approve its tools",
"assign-toolset-everyone": "Bind approved tools into a toolset and request it for every user",
"test-mcp-gateway": "Trace a registered tool from configuration through a live gateway call",
"delegate-entitlement-provisioning": "Configure a proxy binding and delegated provisioning",
"configure-new-app": "Create a manually managed app, ownership, and a custom entitlement",
"request-access": "Request, verify, and revoke access through the approval workflow",
"inspect-and-approve-task": "Inspect a task's current policy step and resolve it safely",
}

// docsGuides maps a guide name to its embedded content. Keep names stable —
// they're part of the CLI's public surface (an agent may hardcode
// "c1i docs guide register-mcp-server" in its own tooling).
Expand All @@ -799,25 +814,36 @@ func guideNames() []string {
return names
}

func completeGuideNames(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
names := guideNames()
completions := make([]string, 0, len(names))
for _, name := range names {
if strings.HasPrefix(name, toComplete) {
completions = append(completions, name+"\t"+guideSummaries[name])
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}

var docsGuideCmd = &cobra.Command{
Use: "guide [name]",
Short: "Print an embedded, task-oriented runbook (no auth required)",
Long: `Print an embedded, task-oriented runbook — a numbered sequence of actual
c1i commands for a common end-to-end workflow.

Run with no argument to list the available guide names. These are static
content embedded in the c1i binary (no network call), unlike "docs search" /
"docs page" which hit the C1 documentation site.
Run with no argument to list the available guide names and their purposes.
These are static content embedded in the c1i binary (no network call), unlike
"docs search" / "docs page" which hit the C1 documentation site.

Examples:
c1i docs guide
c1i docs guide register-mcp-server`,
Args: cobra.MaximumNArgs(1),
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: completeGuideNames,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Available guides:")
for _, name := range guideNames() {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s\n", name)
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %-34s %s\n", name, guideSummaries[name])
}
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\nRun \"c1i docs guide <name>\" to print one.")
return nil
Expand Down
35 changes: 35 additions & 0 deletions cmd/docs_guide_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ type guideInvocation struct {
completeExample bool
}

func trimOutgoingPipeline(line string) string {
var quote rune
var parentheses int
for i, r := range line {
switch {
case quote != 0:
if r == quote {
quote = 0
}
case r == '\'' || r == '"':
quote = r
case r == '(':
parentheses++
case r == ')' && parentheses > 0:
parentheses--
case r == '|' && parentheses == 0 && i > 0 && (line[i-1] == ' ' || line[i-1] == '\t'):
if rest := strings.TrimSpace(line[i+1:]); rest != "" && !strings.HasPrefix(rest, "-") {
return strings.TrimSpace(line[:i])
}
}
}
return line
}

// extractGuideInvocations returns every "c1i ..." invocation in guide, in
// four recognized shapes: a command block (a line trimmed-starting with
// "c1i " — an optional leading shell prompt ("$ " or "> ") stripped first —
Expand Down Expand Up @@ -94,6 +118,7 @@ func extractGuideInvocations(t *testing.T, guide string) []guideInvocation {
line = p
}
if strings.HasPrefix(line, "c1i ") {
line = trimOutgoingPipeline(line)
if loc := redirectRe.FindStringIndex(line); loc != nil {
line = line[:loc[0]]
}
Expand All @@ -116,6 +141,16 @@ func extractGuideInvocations(t *testing.T, guide string) []guideInvocation {
return invocations
}

func TestExtractGuideInvocationsDropsOutgoingPipeline(t *testing.T) {
invocations := extractGuideInvocations(t, "c1i completion powershell | Out-String | Invoke-Expression")
if len(invocations) != 1 {
t.Fatalf("invocation count = %d, want 1", len(invocations))
}
if got, want := invocations[0].text, "c1i completion powershell"; got != want {
t.Errorf("invocation = %q, want %q", got, want)
}
}

// checkUnclaimedMentions flags a "c1i" mention that falls outside all three
// recognized invocation shapes (e.g. unquoted mid-sentence prose, or a line
// prefixed with shell logic) but names a "--flag"/"-f"-shaped token later on
Expand Down
65 changes: 64 additions & 1 deletion cmd/docs_guide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cmd
import (
"bytes"
"errors"
"github.com/spf13/cobra"
"regexp"
"sort"
"strings"
"testing"
Expand All @@ -26,6 +28,64 @@ func TestGuideNamesSorted(t *testing.T) {
}
}

func TestGuideSummariesMatchRegistry(t *testing.T) {
if len(guideSummaries) != len(docsGuides) {
t.Fatalf("guideSummaries has %d entries, want %d", len(guideSummaries), len(docsGuides))
}
for name := range docsGuides {
if strings.TrimSpace(guideSummaries[name]) == "" {
t.Errorf("guide %q has no discovery summary", name)
}
}
}

var embeddedGuideReferenceRE = regexp.MustCompile(`(?:c1i )?docs guide ([a-z-]+)`)

func TestEmbeddedGuideReferencesResolve(t *testing.T) {
sources := make(map[string]string, len(docsGuides)+1)
for name, content := range docsGuides {
sources["guide "+name] = content
}
sources["docs agents"] = agentsTemplate

for source, content := range sources {
for _, match := range embeddedGuideReferenceRE.FindAllStringSubmatch(content, -1) {
target := match[1]
if _, ok := docsGuides[target]; !ok {
t.Errorf("%s references unknown guide %q", source, target)
}
}
}
}

func TestGuideCompletionListsNamesAndSummaries(t *testing.T) {
completions, directive := completeGuideNames(docsGuideCmd, nil, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Fatalf("completion directive = %v, want no-file-completion", directive)
}
if len(completions) != len(docsGuides) {
t.Fatalf("completion count = %d, want %d", len(completions), len(docsGuides))
}
for _, completion := range completions {
name, summary, ok := strings.Cut(completion, "\t")
if !ok || summary != guideSummaries[name] {
t.Errorf("completion %q does not contain the guide summary", completion)
}
}

completions, _ = completeGuideNames(docsGuideCmd, nil, "test-")
if len(completions) != 1 || !strings.HasPrefix(completions[0], "test-mcp-gateway\t") {
t.Errorf("filtered completions = %q, want test-mcp-gateway only", completions)
}
}

func TestAssignToolsetGuideWaitsForEachRequestedGrant(t *testing.T) {
verification := regexp.MustCompile(`(?s)c1i grants list --app-id "\$APP_ID" --entitlement-id "\$ENTITLEMENT_ID"\s*\\?\s*--user-id "\$USER_ID" --wait --wait-min 1`)
if !verification.MatchString(guideAssignToolsetEveryone) {
t.Fatalf("assign-toolset-everyone guide does not verify every requested grant with --wait-min 1")
}
}

// TestGuideRegistryLookup pins that every guide the task requires is
// registered with non-empty content, so a lookup by name never silently
// returns an empty runbook.
Expand Down Expand Up @@ -71,10 +131,13 @@ func TestDocsGuideCmdNoArgListsNames(t *testing.T) {
t.Fatalf("RunE returned unexpected error: %v", err)
}
out := buf.String()
for name := range docsGuides {
for name, summary := range guideSummaries {
if !strings.Contains(out, name) {
t.Errorf("no-arg listing missing guide name %q; got:\n%s", name, out)
}
if !strings.Contains(out, summary) {
t.Errorf("no-arg listing missing guide summary %q; got:\n%s", summary, out)
}
}
}

Expand Down
10 changes: 4 additions & 6 deletions cmd/mcp_gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,16 @@ Subcommands:
call - Invoke a tool and print its result`,
}

// deriveGatewayURL turns an API base URL into the MCP gateway endpoint by
// inserting "-mcp" before the first dot of the host and appending /v1
// (https://acme.conductor.one -> https://acme-mcp.conductor.one/v1).
// deriveGatewayURL turns an API base URL into the MCP gateway endpoint.
func deriveGatewayURL(baseURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return "", &usageError{fmt.Errorf("cannot derive gateway URL from %q", baseURL)}
}
// Insert -mcp into the hostname (not the port): acme.conductor.one ->
// acme-mcp.conductor.one.
hostname := u.Hostname()
if i := strings.Index(hostname, "."); i > 0 {
if i := strings.Index(hostname, "--"); i > 0 {
hostname = hostname[:i] + "-mcp" + hostname[i:]
} else if i := strings.Index(hostname, "."); i > 0 {
hostname = hostname[:i] + "-mcp" + hostname[i:]
} else {
hostname += "-mcp"
Expand Down
1 change: 1 addition & 0 deletions cmd/mcp_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func TestDeriveGatewayURL(t *testing.T) {
{"https://acme.conductor.one", "https://acme-mcp.conductor.one/v1"},
{"https://acme.conductor.one/", "https://acme-mcp.conductor.one/v1"},
{"https://localhost:8080", "https://localhost-mcp:8080/v1"},
{"https://tenant--proxy--environment.example.com", "https://tenant-mcp--proxy--environment.example.com/v1"},
}
for _, c := range cases {
got, err := deriveGatewayURL(c.in)
Expand Down
Loading
Loading