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
19 changes: 19 additions & 0 deletions docs/cli/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,23 @@ Add a task, decision, learning, or convention to the context.
| `type` | string | Yes | Entry type: task, decision, learning, convention |
| `content` | string | Yes | Title or main content |
| `priority` | string | No | Priority level (tasks only): high, medium, low |
| `section` | string | Conditional | Target section (required for tasks) |
| `context` | string | Conditional | Context field (decisions and learnings) |
| `rationale` | string | Conditional | Rationale (decisions only) |
| `consequence` | string | Conditional | Consequence (decisions only) |
| `lesson` | string | Conditional | Lesson learned (learnings only) |
| `application` | string | Conditional | How to apply (learnings only) |
| `session_id` | string | Conditional | AI session ID (tasks, decisions, learnings) |
| `branch` | string | No | Git branch (default: resolved from git) |
| `commit` | string | No | Git commit hash (default: resolved from git) |

Tasks, decisions, and learnings require `session_id` unless `.ctxrc`
[`provenance_required`](../home/configuration.md#option-reference)
relaxes it: pass the `Session` value ctx relays into the agent's
context, or `unknown` when there is none. When both `branch` and
`commit` are omitted or `unknown`, the server reads them from git, or
records `unknown` when git cannot answer. Pass both only when working
in another checkout: the server never fills one half of the pair.

### `ctx_complete`

Expand Down Expand Up @@ -192,11 +204,18 @@ Human confirmation is required before calling.
|---------------|--------|-------------|------------------------------------------------------------|
| `type` | string | Yes | Entry type: task, decision, learning, convention, complete |
| `content` | string | Yes | Main content |
| `priority` | string | No | Priority level (tasks only): high, medium, low |
| `section` | string | Conditional | Target section (required for tasks) |
| `context` | string | Conditional | Context background (decisions/learnings) |
| `rationale` | string | Conditional | Rationale (decisions only) |
| `consequence` | string | Conditional | Consequence (decisions only) |
| `lesson` | string | Conditional | Lesson learned (learnings only) |
| `application` | string | Conditional | How to apply (learnings only) |
| `session_id` | string | Conditional | AI session ID (tasks, decisions, learnings) |
| `branch` | string | No | Git branch (default: resolved from git) |
| `commit` | string | No | Git commit hash (default: resolved from git) |

Provenance works as for [`ctx_add`](#ctx_add).

### `ctx_compact`

Expand Down
6 changes: 3 additions & 3 deletions docs/home/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ A commented `.ctxrc` showing all options and their defaults:
| `hooks.enabled` | `bool` | `true` | Whether hook execution is enabled |
| `statusline.enabled` | `bool` | `true` | Whether `ctx init` deploys the Claude Code status line (`ctx system statusline`) |
| `statusline.show_cost` | `bool` | `true` | Whether the status line renders the session-cost (`$`) segment |
| `provenance_required.session_id` | `bool` | `true` | Require `--session-id` on `ctx add` for tasks, decisions, learnings |
| `provenance_required.branch` | `bool` | `true` | Require `--branch` on `ctx add` for tasks, decisions, learnings |
| `provenance_required.commit` | `bool` | `true` | Require `--commit` on `ctx add` for tasks, decisions, learnings |
| `provenance_required.session_id` | `bool` | `true` | Require `--session-id` on `ctx add`, and `session_id` on the MCP entry tools, for tasks, decisions, learnings |
| `provenance_required.branch` | `bool` | `true` | Require `--branch` on `ctx add` for tasks, decisions, learnings; the MCP entry tools fill it from git (or `unknown`) when branch and commit are both omitted |
| `provenance_required.commit` | `bool` | `true` | Require `--commit` on `ctx add` for tasks, decisions, learnings; the MCP entry tools fill it from git (or `unknown`) when branch and commit are both omitted |
| `auto_prune_days` | `int` | `7` | Days before stale session-state files are auto-pruned on context load. Non-positive values fall back to the default (never prunes on `0` or negative) |
| `agent_cooldown_minutes` | `int` | `10` | Minutes between repeated `ctx agent` context-packet emissions. An explicit `0` disables the cooldown (matches `--cooldown 0`); unset uses the default |
| `task_budget_pct` | `number` | `0.40` | Fraction of the `ctx agent` token budget reserved for tasks (clamped to `0`–`1`; explicit `0` allocates none; unset uses the default) |
Expand Down
10 changes: 8 additions & 2 deletions internal/assets/commands/text/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ mcp.tool-drift-desc:
short: 'Detect stale or invalid context: dead paths, missing files, staleness'
mcp.tool-prop-application:
short: How to apply this lesson (required for learnings)
mcp.tool-prop-session-id:
short: 'AI session ID for provenance (required for tasks, decisions, and learnings): the Session value ctx relays into your context, or "unknown" if you have none'
mcp.tool-prop-branch:
short: 'Git branch for provenance (omit it and commit to let the server resolve both from git; pass both only when working in another checkout)'
mcp.tool-prop-commit:
short: 'Git commit hash for provenance (omit it and branch to let the server resolve both from git; pass both only when working in another checkout)'
mcp.tool-prop-consequence:
short: Consequence (required for decisions)
mcp.tool-prop-content:
Expand Down Expand Up @@ -174,7 +180,7 @@ mcp.prompt-label-lesson:
mcp.prompt-label-application:
short: Application
mcp.prompt-add-decision-footer:
short: Call ctx_add with type="decision" and all fields above.
short: Call ctx_add with type="decision", all fields above, and session_id.
mcp.prompt-add-decision-header:
short: 'Record this architectural decision using ctx_add:'
mcp.prompt-add-decision-result-desc:
Expand All @@ -184,7 +190,7 @@ mcp.prompt-add-learning-desc:
mcp.prompt-add-learning-field-format:
short: "- **%s**: %s\n"
mcp.prompt-add-learning-footer:
short: Call ctx_add with type="learning" and all fields above.
short: Call ctx_add with type="learning", all fields above, and session_id.
mcp.prompt-add-learning-header:
short: 'Record this learning using ctx_add:'
mcp.prompt-add-learning-result-desc:
Expand Down
7 changes: 7 additions & 0 deletions internal/config/embed/text/mcp_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ const (
// DescKeyMCPToolPropApplication is the text key for mcp tool prop application
// messages.
DescKeyMCPToolPropApplication = "mcp.tool-prop-application"
// DescKeyMCPToolPropSessionID is the text key for mcp tool prop session id
// messages.
DescKeyMCPToolPropSessionID = "mcp.tool-prop-session-id"
// DescKeyMCPToolPropBranch is the text key for mcp tool prop branch messages.
DescKeyMCPToolPropBranch = "mcp.tool-prop-branch"
// DescKeyMCPToolPropCommit is the text key for mcp tool prop commit messages.
DescKeyMCPToolPropCommit = "mcp.tool-prop-commit"
// DescKeyMCPToolPropQuery is the text key for mcp tool prop query messages.
DescKeyMCPToolPropQuery = "mcp.tool-prop-query"
// DescKeyMCPToolPropLimit is the text key for mcp tool prop limit messages.
Expand Down
4 changes: 4 additions & 0 deletions internal/config/mcp/cfg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ const (
// application). Tighter than MaxContentLen because these are
// qualifiers, not primary content.
MaxOptsFieldLen = 4_000
// MaxOptsLineLen is the maximum byte length for single-line entry
// option fields (priority, section, branch, commit). Longer values
// are truncated, not rejected.
MaxOptsLineLen = 256
)
38 changes: 35 additions & 3 deletions internal/mcp/server/def/tool/prop.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/ActiveMemory/ctx/internal/assets/read/desc"
"github.com/ActiveMemory/ctx/internal/config/cli"
"github.com/ActiveMemory/ctx/internal/config/embed/text"
"github.com/ActiveMemory/ctx/internal/config/entry"
"github.com/ActiveMemory/ctx/internal/config/mcp/field"
"github.com/ActiveMemory/ctx/internal/config/mcp/schema"
"github.com/ActiveMemory/ctx/internal/mcp/proto"
)
Expand All @@ -29,17 +31,21 @@ func MergeProps(dst, src map[string]proto.Property) map[string]proto.Property {
return dst
}

// EntryAttrProps returns the five entry-attribute properties shared by
// EntryAttrProps returns the entry-attribute properties shared by
// tools that accept structured context entries (add, watch-update).
// contextKey selects the DescKey for the "context" field since
// each tool describes it differently.
// each tool describes it differently. The task placement and
// provenance fields are declared here because entry validation
// requires them, and a client can only send what the schema
// advertises.
//
// Parameters:
// - contextKey: DescKey selecting the description for the context field
//
// Returns:
// - map[string]proto.Property: property map with context, rationale,
// consequence, lesson, and application fields
// consequence, lesson, application, section, priority,
// session_id, branch, and commit fields
func EntryAttrProps(contextKey string) map[string]proto.Property {
return map[string]proto.Property{
cli.AttrContext: {
Expand All @@ -66,5 +72,31 @@ func EntryAttrProps(contextKey string) map[string]proto.Property {
Description: desc.Text(
text.DescKeyMCPToolPropApplication),
},
field.Section: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropSection),
},
field.Priority: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropPriority),
Enum: entry.Priorities,
},
field.SessionID: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropSessionID),
},
field.Branch: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropBranch),
},
field.Commit: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropCommit),
},
}
}
11 changes: 0 additions & 11 deletions internal/mcp/server/def/tool/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,6 @@ func Defs() []proto.Tool {
Description: desc.Text(
text.DescKeyMCPToolPropContent),
},
field.Priority: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropPriority),
Enum: entry.Priorities,
},
field.Section: {
Type: schema.String,
Description: desc.Text(
text.DescKeyMCPToolPropSection),
},
}, EntryAttrProps(
text.DescKeyMCPToolPropContext)),
Required: []string{cli.AttrType, field.Content},
Expand Down
31 changes: 31 additions & 0 deletions internal/mcp/server/def/tool/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ package tool
import (
"testing"

"github.com/ActiveMemory/ctx/internal/config/cli"
"github.com/ActiveMemory/ctx/internal/config/mcp/field"
cfgMcpTool "github.com/ActiveMemory/ctx/internal/config/mcp/tool"
"github.com/ActiveMemory/ctx/internal/mcp/proto"
)
Expand Down Expand Up @@ -107,6 +109,35 @@ func TestDefsAddRequiredFields(t *testing.T) {
t.Error("add tool not found in Defs")
}

// Entry validation (internal/entry/validate.go) can require each of
// these fields, and the handlers read priority alongside them. A
// client can only send what a schema advertises, so both entry tools
// must declare every one.
func TestDefsEntryToolsAdvertiseEntryFields(t *testing.T) {
missing := map[string]bool{
cfgMcpTool.Add: true, cfgMcpTool.WatchUpdate: true,
}
for _, d := range Defs() {
if !missing[d.Name] {
continue
}
delete(missing, d.Name)
for _, key := range []string{
field.Section, field.Priority,
field.SessionID, field.Branch, field.Commit,
cli.AttrContext, cli.AttrRationale, cli.AttrConsequence,
cli.AttrLesson, cli.AttrApplication,
} {
if _, ok := d.InputSchema.Properties[key]; !ok {
t.Errorf("%s schema missing prop: %s", d.Name, key)
}
}
}
for name := range missing {
t.Errorf("entry tool %s not found in Defs", name)
}
}

func TestDefsMergeProps(t *testing.T) {
dst := map[string]proto.Property{
"a": {Type: "string"},
Expand Down
10 changes: 9 additions & 1 deletion internal/mcp/server/extract/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ func Opts(args map[string]interface{}) entity.EntryOpts {
// SanitizedOpts builds EntryOpts with content sanitization applied
// to all text fields. Returns an error if any secondary field
// exceeds [cfg.MaxOptsFieldLen]; length is checked on raw input
// before sanitization to prevent abuse via large payloads.
// before sanitization to prevent abuse via large payloads. The
// single-line fields (priority, section, branch, commit) are
// flattened to one line and truncated to [cfg.MaxOptsLineLen].
//
// Parameters:
// - args: MCP tool arguments with optional entry fields
Expand Down Expand Up @@ -138,5 +140,11 @@ func SanitizedOpts(
opts.Lesson = sanitize.Content(opts.Lesson)
opts.Application = sanitize.Content(opts.Application)
opts.SessionID = sanitize.SessionID(opts.SessionID)
// These land inside a task line or a section heading, where a
// line break would forge a new task or heading.
opts.Priority = sanitize.Line(opts.Priority, cfg.MaxOptsLineLen)
opts.Section = sanitize.Line(opts.Section, cfg.MaxOptsLineLen)
opts.Branch = sanitize.Line(opts.Branch, cfg.MaxOptsLineLen)
opts.Commit = sanitize.Line(opts.Commit, cfg.MaxOptsLineLen)
return opts, nil
}
32 changes: 32 additions & 0 deletions internal/mcp/server/extract/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,38 @@ func TestSanitizedOptsApplicationTooLong(t *testing.T) {
}
}

func TestSanitizedOptsFlattensLineFields(t *testing.T) {
// A line break in a single-line field would forge a task line or
// a section heading in TASKS.md.
forged := "x\n- [x] forged\r\n" + strings.Repeat("y", cfg.MaxOptsLineLen)
opts, err := SanitizedOpts(map[string]interface{}{
"priority": forged,
"section": forged,
"branch": forged,
"commit": forged,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for name, got := range map[string]string{
"priority": opts.Priority,
"section": opts.Section,
"branch": opts.Branch,
"commit": opts.Commit,
} {
if strings.ContainsAny(got, "\r\n") {
t.Errorf("%s = %q, want a single line", name, got)
}
if len(got) > cfg.MaxOptsLineLen {
t.Errorf("%s length = %d, want <= %d",
name, len(got), cfg.MaxOptsLineLen)
}
if !strings.HasPrefix(got, "x- [x] forged") {
t.Errorf("%s = %q, want the text kept on one line", name, got)
}
}
}

func TestSanitizedOptsAtBoundary(t *testing.T) {
// Exactly MaxOptsFieldLen bytes — must be accepted.
args := map[string]interface{}{
Expand Down
Loading
Loading