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
4 changes: 3 additions & 1 deletion docs/docs/reference/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ description: Exact inputs, listed descriptions, successful structured outputs, d

Each input is an object with one required string property. Additional properties are rejected by the SDK before subject resolution or service work. Every valid call then resolves a trusted subject through `mcpserver.InvocationResolver` before it reaches the CodeMode service.

On success, the official SDK returns the documented value in `CallToolResult.StructuredContent` and one JSON `TextContent` item that mirrors it. The schemas below describe the structured value, not the surrounding MCP result. Each tool's listed `tools/list` description is the model-facing authoring contract for that tool.
On success, `CallToolResult.StructuredContent` contains the value described by the tool's `outputSchema`, and one JSON `TextContent` item mirrors the same value. The schemas below are the `outputSchema` values advertised by `tools/list`; they describe the successful value itself, not the surrounding MCP result. Each tool's listed `tools/list` description is the model-facing authoring contract for that tool.

## `search_api`

Expand Down Expand Up @@ -132,6 +132,8 @@ For the site-wide sample, the requested name is `records.lookup`. Its stable ID,
}
```

The required `input` and `output` properties are always arrays. A capability with no input or output fields uses `[]`, not `null`.

`input` and `output` preserve Go field declaration order. Field-shape types have these values:

| Position | Go field type | `type` value | `required` |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/meigma/codemode
go 1.26.6

require (
github.com/google/jsonschema-go v0.4.3
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/open-policy-agent/opa v1.19.1
github.com/stretchr/testify v1.12.1
Expand All @@ -15,7 +16,6 @@ require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.2.1 // indirect
Expand Down
28 changes: 28 additions & 0 deletions mcpserver/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ func TestActualMCPSecureLoop(t *testing.T) {
names = append(names, tool.Name)
}
assert.ElementsMatch(t, []string{"search_api", "describe_api", "execute"}, names)
requireSearchAPIOutputSchema(t, listedOutputSchema(t, listed.Tools, "search_api"))
requireDescribeAPIOutputSchema(t, listedOutputSchema(t, listed.Tools, "describe_api"))
requireExecuteOutputSchema(t, listedOutputSchema(t, listed.Tools, "execute"))

searched, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: "search_api",
Expand All @@ -258,6 +261,8 @@ func TestActualMCPSecureLoop(t *testing.T) {
assert.Equal(t, "records.lookup(*, key: str, limit: int | None)", searchResults[0].Signature)
assert.Equal(t, "Look up one record by key.", searchResults[0].Summary)
assertDiscoveryOmitsGoTypeNames(t, searched, "lookupResult", "StatusResult")
requireNonNullJSONArray(t, searched.StructuredContent)
requireJSONTextMirror(t, searched, searchResults)

described, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe_api",
Expand All @@ -284,6 +289,7 @@ func TestActualMCPSecureLoop(t *testing.T) {
assert.Equal(t, "int", description.Output[1].Type)
assert.True(t, description.Output[1].Required)
assertDiscoveryOmitsGoTypeNames(t, described, "lookupResult", "StatusResult")
requireNonNullDescribeFieldArrays(t, described)

statusSearch, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: "search_api",
Expand All @@ -297,6 +303,8 @@ func TestActualMCPSecureLoop(t *testing.T) {
assert.Equal(t, "health.status", statusResults[0].Name)
assert.Equal(t, "health.status()", statusResults[0].Signature)
assertDiscoveryOmitsGoTypeNames(t, statusSearch, "lookupResult", "StatusResult")
requireNonNullJSONArray(t, statusSearch.StructuredContent)
requireJSONTextMirror(t, statusSearch, statusResults)

statusDescribed, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe_api",
Expand All @@ -312,6 +320,17 @@ func TestActualMCPSecureLoop(t *testing.T) {
require.Len(t, statusDescription.Output, 1)
assert.Equal(t, "state", statusDescription.Output[0].Name)
assertDiscoveryOmitsGoTypeNames(t, statusDescribed, "lookupResult", "StatusResult")
requireNonNullDescribeFieldArrays(t, statusDescribed)
require.Empty(t, requireJSONObject(t, statusDescribed.StructuredContent)["input"])

emptySearch, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: "search_api",
Arguments: map[string]any{"query": "zzzz-no-match"},
})
require.NoError(t, err)
assertSuccessfulTool(t, emptySearch)
assertNoCanary(t, emptySearch)
requireSuccessfulStructuredValue(t, emptySearch, []any{})

hidden, err := session.CallTool(t.Context(), &mcp.CallToolParams{
Name: "describe_api",
Expand Down Expand Up @@ -507,3 +526,12 @@ func assertDiscoveryOmitsGoTypeNames(t *testing.T, result *mcp.CallToolResult, f
assert.NotContains(t, text.Text, name)
}
}

// requireNonNullDescribeFieldArrays requires describe structured content to carry
// non-null input and output arrays.
func requireNonNullDescribeFieldArrays(t *testing.T, result *mcp.CallToolResult) {
t.Helper()
object := requireJSONObject(t, result.StructuredContent)
requireNonNullJSONArray(t, object["input"])
requireNonNullJSONArray(t, object["output"])
}
114 changes: 106 additions & 8 deletions mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"reflect"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/meigma/codemode"
Expand Down Expand Up @@ -66,19 +67,64 @@ func New(service Service, resolver InvocationResolver) (*mcp.Server, error) {
return nil, fmt.Errorf("%w: invocation resolver is required", codemode.ErrInvalidRegistration)
}

searchOutputSchema, schemaErr := jsonschema.For[[]codemode.SearchResult](nil)
if schemaErr != nil {
return nil, fmt.Errorf("%w: search_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}
schemaErr = requireNonNullArray(searchOutputSchema)
if schemaErr != nil {
return nil, fmt.Errorf("%w: search_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}
schemaErr = requireResolvedSchema(searchOutputSchema)
if schemaErr != nil {
return nil, fmt.Errorf("%w: search_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}

describeOutputSchema, schemaErr := jsonschema.For[codemode.Description](nil)
if schemaErr != nil {
return nil, fmt.Errorf("%w: describe_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}
if describeOutputSchema == nil || describeOutputSchema.Properties == nil {
return nil, fmt.Errorf("%w: describe_api output schema: missing properties", codemode.ErrInvalidRegistration)
}
schemaErr = requireNonNullArray(describeOutputSchema.Properties["input"])
if schemaErr != nil {
return nil, fmt.Errorf("%w: describe_api output schema: input: %w", codemode.ErrInvalidRegistration, schemaErr)
}
schemaErr = requireNonNullArray(describeOutputSchema.Properties["output"])
if schemaErr != nil {
return nil, fmt.Errorf("%w: describe_api output schema: output: %w", codemode.ErrInvalidRegistration, schemaErr)
}
schemaErr = requireResolvedSchema(describeOutputSchema)
if schemaErr != nil {
return nil, fmt.Errorf("%w: describe_api output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}

executeOutputSchema, schemaErr := jsonschema.For[executeOutput](nil)
if schemaErr != nil {
return nil, fmt.Errorf("%w: execute output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}
schemaErr = requireResolvedSchema(executeOutputSchema)
if schemaErr != nil {
return nil, fmt.Errorf("%w: execute output schema: %w", codemode.ErrInvalidRegistration, schemaErr)
}

bound := &adapter{service: service, resolver: resolver}
server := mcp.NewServer(&mcp.Implementation{Name: "codemode", Version: "1"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "search_api",
Description: "Search enabled names and summaries with a short literal substring. Retry an empty result with a shorter term.",
Name: "search_api",
Description: "Search enabled names and summaries with a short literal substring. Retry an empty result with a shorter term.",
OutputSchema: searchOutputSchema,
}, bound.search)
mcp.AddTool(server, &mcp.Tool{
Name: "describe_api",
Description: "Describe one enabled capability by the exact name returned by search_api, without whitespace or case changes.",
Name: "describe_api",
Description: "Describe one enabled capability by the exact name returned by search_api, without whitespace or case changes.",
OutputSchema: describeOutputSchema,
}, bound.describe)
mcp.AddTool(server, &mcp.Tool{
Name: "execute",
Description: "Execute one Starlark program that defines def main(): with zero arguments, calls only names confirmed through search_api and describe_api inside main, and returns main's final result.",
Name: "execute",
Description: "Execute one Starlark program that defines def main(): with zero arguments, calls only names confirmed through search_api and describe_api inside main, and returns main's final result.",
OutputSchema: executeOutputSchema,
}, bound.execute)
return server, nil
}
Expand All @@ -95,8 +141,8 @@ func (bound *adapter) search(
}
return bound.service.Search(input.Query)
})
if outcome.value == nil && outcome.err == nil {
outcome.value = []codemode.SearchResult{}
if outcome.err == nil {
outcome.value = nonNilSlice(outcome.value)
}
return nil, outcome.value, outcome.err
}
Expand All @@ -113,6 +159,10 @@ func (bound *adapter) describe(
}
return bound.service.Describe(codemode.CapabilityName(input.Name))
})
if outcome.err == nil {
outcome.value.Input = nonNilSlice(outcome.value.Input)
outcome.value.Output = nonNilSlice(outcome.value.Output)
}
return nil, outcome.value, outcome.err
}

Expand Down Expand Up @@ -208,3 +258,51 @@ func isNil(value any) bool {
kind == reflect.Slice
return nilable && reflected.IsNil()
}

// nonNilSlice returns values, or an empty slice when values is nil.
func nonNilSlice[T any](values []T) []T {
if values == nil {
return []T{}
}
return values
}

// jsonSchemaArrayType is the JSON Schema name for an array.
const jsonSchemaArrayType = "array"

// requireNonNullArray narrows a nullable JSON Schema slice node to type array.
func requireNonNullArray(schema *jsonschema.Schema) error {
if schema == nil {
return errors.New("schema is required")
}
if schema.Type == jsonSchemaArrayType && schema.Types == nil {
return nil
}
hasArray := false
hasNull := false
for _, typ := range schema.Types {
switch typ {
case jsonSchemaArrayType:
hasArray = true
case "null":
hasNull = true
default:
return fmt.Errorf("schema type includes %q", typ)
}
}
if schema.Type != "" || !hasArray || !hasNull {
return errors.New("schema must be a nullable array")
}
schema.Type = jsonSchemaArrayType
schema.Types = nil
return nil
}

// requireResolvedSchema reports whether schema can be used as a tool output schema.
func requireResolvedSchema(schema *jsonschema.Schema) error {
if schema == nil {
return errors.New("inferred schema is nil")
}
_, err := schema.Resolve(nil)
return err
}
Loading