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
4 changes: 4 additions & 0 deletions internal/assets/commands/text/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ mcp.err-unknown-prompt:
short: 'unknown prompt: %s'
mcp.err-uri-required:
short: uri is required
mcp.err-input-too-long:
short: '%s exceeds maximum length (%d bytes)'
mcp.err-unknown-entry-type:
short: 'unknown entry type: %s'
mcp.format-watch-completed:
short: 'Completed: %s'
mcp.format-wrote:
Expand Down
6 changes: 6 additions & 0 deletions internal/config/embed/text/mcp_err.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ const (
DescKeyMCPErrUnknownPrompt = "mcp.err-unknown-prompt"
// DescKeyMCPErrURIRequired is the text key for mcp err uri required messages.
DescKeyMCPErrURIRequired = "mcp.err-uri-required"
// DescKeyMCPErrInputTooLong is the text key for mcp err
// input too long messages.
DescKeyMCPErrInputTooLong = "mcp.err-input-too-long"
// DescKeyMCPErrUnknownEntryType is the text key for mcp
// err unknown entry type messages.
DescKeyMCPErrUnknownEntryType = "mcp.err-unknown-entry-type"
)
15 changes: 15 additions & 0 deletions internal/config/mcp/cfg/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,23 @@ const (

// DefaultSourceLimit is the max sessions returned by ctx_journal_source.
DefaultSourceLimit = 5
// MaxSourceLimit caps the source limit to prevent unbounded queries.
MaxSourceLimit = 100
// MinWordLen is the shortest word considered for overlap matching.
MinWordLen = 4
// MinWordOverlap is the minimum word matches to signal task completion.
MinWordOverlap = 2

// --- Input length limits (MCP-SAN.1) ---

// MaxContentLen is the maximum byte length for entry content fields.
MaxContentLen = 32_000
// MaxNameLen is the maximum byte length for tool/prompt/resource names.
MaxNameLen = 256
// MaxQueryLen is the maximum byte length for search queries.
MaxQueryLen = 1_000
// MaxCallerLen is the maximum byte length for caller identifiers.
MaxCallerLen = 128
// MaxURILen is the maximum byte length for resource URIs.
MaxURILen = 512
)
33 changes: 33 additions & 0 deletions internal/config/regex/sanitize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package regex

import "regexp"

// SanEntryHeader matches entry headers like "## [2026-" in
// content sanitization (MCP-SAN.3).
var SanEntryHeader = regexp.MustCompile(
`(?m)^##\s+\[\d{4}-`,
)

// SanTaskCheckbox matches task checkboxes "- [ ]" and
// "- [x]" in content sanitization.
var SanTaskCheckbox = regexp.MustCompile(
`(?m)^-\s+\[[x ]\]`,
)

// SanConstitutionRule matches constitution rule format
// "- [ ] **Never" in content sanitization.
var SanConstitutionRule = regexp.MustCompile(
`(?m)^-\s+\[[x ]\]\s+\*\*[A-Z]`,
)

// SanSessionIDUnsafe matches characters not safe for session
// IDs in file paths: anything outside [a-zA-Z0-9._-].
var SanSessionIDUnsafe = regexp.MustCompile(
`[^a-zA-Z0-9._-]`,
)
11 changes: 11 additions & 0 deletions internal/config/sanitize/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

// Package sanitize holds string and length constants used by the
// sanitize layer. These constants define replacement characters,
// special strings to detect, and maximum lengths for sanitized fields.
// Part of the internal subsystem.
package sanitize
34 changes: 34 additions & 0 deletions internal/config/sanitize/sanitize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package sanitize

// Sanitize-layer string and length constants.
const (
// NullByte is the null character stripped from untrusted input.
NullByte = "\x00"

// DotDot is a path traversal sequence.
DotDot = ".."

// ForwardSlash is the forward slash stripped from session IDs.
ForwardSlash = "/"

// Backslash is the backslash stripped from session IDs.
Backslash = "\\"

// HyphenReplace is the replacement character for unsafe
// session ID characters.
HyphenReplace = "-"

// EscapePrefix is the backslash prefix for escaping Markdown
// structural patterns.
EscapePrefix = `\`

// MaxSessionIDLen is the maximum byte length for a session
// identifier.
MaxSessionIDLen = 128
)
177 changes: 177 additions & 0 deletions internal/entity/mcp_session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package entity

import (
"testing"
"time"
)

func TestNewMCPSession(t *testing.T) {
s := NewMCPSession()
if s.ToolCalls != 0 {
t.Errorf("ToolCalls = %d, want 0", s.ToolCalls)
}
if s.AddsPerformed == nil {
t.Fatal("AddsPerformed should be initialized")
}
if len(s.AddsPerformed) != 0 {
t.Errorf(
"AddsPerformed length = %d, want 0",
len(s.AddsPerformed),
)
}
if s.SessionStartedAt.IsZero() {
t.Error("SessionStartedAt should be set")
}
if len(s.PendingFlush) != 0 {
t.Errorf(
"PendingFlush length = %d, want 0",
len(s.PendingFlush),
)
}
}

func TestRecordToolCall(t *testing.T) {
s := NewMCPSession()
s.RecordToolCall()
if s.ToolCalls != 1 {
t.Errorf("ToolCalls = %d, want 1", s.ToolCalls)
}
s.RecordToolCall()
s.RecordToolCall()
if s.ToolCalls != 3 {
t.Errorf("ToolCalls = %d, want 3", s.ToolCalls)
}
}

func TestRecordAdd(t *testing.T) {
s := NewMCPSession()
s.RecordAdd("task")
s.RecordAdd("task")
s.RecordAdd("decision")
if s.AddsPerformed["task"] != 2 {
t.Errorf(
"task adds = %d, want 2",
s.AddsPerformed["task"],
)
}
if s.AddsPerformed["decision"] != 1 {
t.Errorf(
"decision adds = %d, want 1",
s.AddsPerformed["decision"],
)
}
}

func TestQueuePendingUpdate(t *testing.T) {
s := NewMCPSession()
now := time.Now()
s.QueuePendingUpdate(PendingUpdate{
Type: "task",
Content: "Build feature",
QueuedAt: now,
})
if len(s.PendingFlush) != 1 {
t.Fatalf(
"PendingFlush length = %d, want 1",
len(s.PendingFlush),
)
}
pu := s.PendingFlush[0]
if pu.Type != "task" {
t.Errorf(
"Type = %q, want %q",
pu.Type, "task",
)
}
if pu.Content != "Build feature" {
t.Errorf(
"Content = %q, want %q",
pu.Content, "Build feature",
)
}
}

func TestPendingCount(t *testing.T) {
s := NewMCPSession()
if s.PendingCount() != 0 {
t.Errorf("PendingCount = %d, want 0", s.PendingCount())
}
s.QueuePendingUpdate(PendingUpdate{Type: "task", Content: "A"})
s.QueuePendingUpdate(PendingUpdate{Type: "decision", Content: "B"})
if s.PendingCount() != 2 {
t.Errorf("PendingCount = %d, want 2", s.PendingCount())
}
}

func TestRecordSessionStart(t *testing.T) {
s := NewMCPSession()
if s.SessionStarted {
t.Error("SessionStarted should be false initially")
}
before := time.Now()
s.RecordSessionStart()
if !s.SessionStarted {
t.Error("SessionStarted should be true after RecordSessionStart")
}
if s.SessionStartedAt.Before(before) {
t.Error("SessionStartedAt should be updated to now or later")
}
}

func TestRecordContextLoaded(t *testing.T) {
s := NewMCPSession()
if s.ContextLoaded {
t.Error("ContextLoaded should be false initially")
}
s.RecordContextLoaded()
if !s.ContextLoaded {
t.Error("ContextLoaded should be true after RecordContextLoaded")
}
}

func TestRecordDriftCheck(t *testing.T) {
s := NewMCPSession()
if !s.LastDriftCheck.IsZero() {
t.Error("LastDriftCheck should be zero initially")
}
before := time.Now()
s.RecordDriftCheck()
if s.LastDriftCheck.Before(before) {
t.Error("LastDriftCheck should be updated to now or later")
}
}

func TestRecordContextWrite(t *testing.T) {
s := NewMCPSession()
s.CallsSinceWrite = 5
before := time.Now()
s.RecordContextWrite()
if s.LastContextWrite.Before(before) {
t.Error("LastContextWrite should be updated to now or later")
}
if s.CallsSinceWrite != 0 {
t.Errorf("CallsSinceWrite = %d, want 0 after RecordContextWrite", s.CallsSinceWrite)
}
}

func TestIncrementCallsSinceWrite(t *testing.T) {
s := NewMCPSession()
if s.CallsSinceWrite != 0 {
t.Errorf("CallsSinceWrite = %d, want 0 initially", s.CallsSinceWrite)
}
s.IncrementCallsSinceWrite()
if s.CallsSinceWrite != 1 {
t.Errorf("CallsSinceWrite = %d, want 1", s.CallsSinceWrite)
}
s.IncrementCallsSinceWrite()
s.IncrementCallsSinceWrite()
if s.CallsSinceWrite != 3 {
t.Errorf("CallsSinceWrite = %d, want 3", s.CallsSinceWrite)
}
}
16 changes: 16 additions & 0 deletions internal/err/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,19 @@ func UnknownEventType(eventType string) error {
eventType,
)
}

// InputTooLong returns an error indicating that a field exceeds the
// maximum allowed byte length.
//
// Parameters:
// - field: the name of the field that was too long
// - maxLen: the maximum allowed byte length
//
// Returns:
// - error: "<field> exceeds maximum length (<maxLen> bytes)"
func InputTooLong(field string, maxLen int) error {
return fmt.Errorf(
desc.Text(text.DescKeyMCPErrInputTooLong),
field, maxLen,
)
}
Loading
Loading