Skip to content

Commit 2385535

Browse files
committed
re-export MCP functionality for dependents
The contents of the mcpserver/ directory were written by Claude, in another project, to provide the desired exported interface without having to reimplement or copy logic. This makes character better for embedding. (Not great, there's a lot of CLI assumptions so our dependency chain is not as small as it could be, but ... marginally better). The changes to mcpstdio were mostly what Claude wanted, but rather than have both `toolEntry` and `ToolEntry` I unified them.
1 parent a93410c commit 2385535

File tree

4 files changed

+276
-9
lines changed

4 files changed

+276
-9
lines changed

internal/mcpstdio/mcpstdio.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"io"
2424
"os"
25+
"slices"
2526
)
2627

2728
// Handler processes a single tool call. It receives the tool arguments as
@@ -35,17 +36,19 @@ type ToolDef struct {
3536
InputSchema json.RawMessage // hand-written JSON Schema object
3637
}
3738

38-
type toolEntry struct {
39-
def ToolDef
40-
handler Handler
39+
// ToolEntry exposes a registered tool's definition and handler for use by
40+
// the public mcpserver bridge package.
41+
type ToolEntry struct {
42+
Def ToolDef
43+
Handler Handler
4144
}
4245

4346
// Server is a tool-only MCP stdio server.
4447
type Server struct {
4548
name string
4649
version string
4750
instructions string
48-
tools []toolEntry
51+
tools []ToolEntry
4952
byName map[string]int
5053
}
5154

@@ -69,9 +72,25 @@ func (s *Server) SetInstructions(text string) {
6972
// AddTool registers a tool. Registration order is preserved in tools/list.
7073
func (s *Server) AddTool(def ToolDef, h Handler) {
7174
s.byName[def.Name] = len(s.tools)
72-
s.tools = append(s.tools, toolEntry{def: def, handler: h})
75+
s.tools = append(s.tools, ToolEntry{Def: def, Handler: h})
7376
}
7477

78+
// Tools returns a snapshot of all registered tools in registration order.
79+
func (s *Server) Tools() []ToolEntry {
80+
return slices.Clone(s.tools)
81+
}
82+
83+
// Instructions returns the instructions string, or "" if none was set.
84+
func (s *Server) Instructions() string {
85+
return s.instructions
86+
}
87+
88+
// Name returns the server name.
89+
func (s *Server) Name() string { return s.name }
90+
91+
// Version returns the server version.
92+
func (s *Server) Version() string { return s.version }
93+
7594
// ServeStdio runs the MCP server on os.Stdin / os.Stdout.
7695
func (s *Server) ServeStdio(ctx context.Context) error {
7796
return s.ServeConn(ctx, os.Stdin, os.Stdout)
@@ -206,9 +225,9 @@ func (s *Server) handleToolsList(w io.Writer, id json.RawMessage) {
206225
items := make([]toolItem, len(s.tools))
207226
for i, t := range s.tools {
208227
items[i] = toolItem{
209-
Name: t.def.Name,
210-
Description: t.def.Description,
211-
InputSchema: t.def.InputSchema,
228+
Name: t.Def.Name,
229+
Description: t.Def.Description,
230+
InputSchema: t.Def.InputSchema,
212231
}
213232
}
214233
writeResponse(w, id, result{Tools: items})
@@ -239,7 +258,7 @@ func (s *Server) handleToolsCall(ctx context.Context, w io.Writer, id json.RawMe
239258
IsError bool `json:"isError,omitempty"`
240259
}
241260

242-
text, err := s.tools[idx].handler(ctx, p.Arguments)
261+
text, err := s.tools[idx].Handler(ctx, p.Arguments)
243262
if err != nil {
244263
writeResponse(w, id, callResult{
245264
Content: []contentItem{{Type: "text", Text: err.Error()}},

mcpserver/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
character :: mcpserver
2+
======================
3+
4+
This sub-package is not used by the character CLI tool itself.
5+
6+
This is a bridge to export a limited supportable API for use when character is
7+
a library used by other tools.
8+
9+
If you're exploring the character code-base, you can mostly ignore this. You
10+
just need to ensure that the tests continue to pass without changing the API
11+
incompatibly.

mcpserver/mcpserver.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright © 2026 Phil Pennock.
2+
// All rights reserved, except as granted under license.
3+
// Licensed per file LICENSE.txt
4+
5+
// Package mcpserver re-exports the MCP server core from internal/mcpstdio so
6+
// that consumers outside this module can inspect registered tools and re-use
7+
// handlers in alternative transports (e.g. HTTP via the official MCP SDK).
8+
//
9+
// The Server type embeds the internal implementation; this package adds only
10+
// the [ToolRegistration] type and the [Server.Tools] accessor.
11+
package mcpserver
12+
13+
import (
14+
"context"
15+
"io"
16+
17+
"github.com/philpennock/character/internal/mcpstdio"
18+
)
19+
20+
// Handler processes a single tool call. It receives the tool arguments as
21+
// raw JSON and returns a result string, or an error if the call failed.
22+
//
23+
// This is a type alias for the internal handler signature so that callers
24+
// outside this module can reference it without importing internal packages.
25+
type Handler = mcpstdio.Handler
26+
27+
// ToolDef describes a single MCP tool for registration and for tools/list.
28+
//
29+
// This is a type alias for the internal tool definition so that callers
30+
// outside this module can reference it without importing internal packages.
31+
type ToolDef = mcpstdio.ToolDef
32+
33+
// ToolRegistration pairs a [ToolDef] with its [Handler], allowing external
34+
// consumers to iterate over all registered tools and re-register them on a
35+
// different transport.
36+
type ToolRegistration struct {
37+
ToolDef
38+
Handler Handler
39+
}
40+
41+
// Server wraps [mcpstdio.Server] and exposes the registered tools for
42+
// external consumption. All stdio-serving methods delegate directly.
43+
type Server struct {
44+
inner *mcpstdio.Server
45+
}
46+
47+
// NewServer creates a Server with the given name and version strings (used in
48+
// the InitializeResult serverInfo).
49+
func NewServer(name, version string) *Server {
50+
return &Server{inner: mcpstdio.NewServer(name, version)}
51+
}
52+
53+
// SetInstructions sets the instructions string returned in the
54+
// InitializeResult. Per MCP 2025-03-26 §Lifecycle, clients MAY surface this
55+
// to the model to guide tool discovery and usage.
56+
func (s *Server) SetInstructions(text string) {
57+
s.inner.SetInstructions(text)
58+
}
59+
60+
// Instructions returns the instructions string, or "" if none was set.
61+
func (s *Server) Instructions() string {
62+
return s.inner.Instructions()
63+
}
64+
65+
// AddTool registers a tool. Registration order is preserved in tools/list
66+
// and in the slice returned by [Server.Tools].
67+
func (s *Server) AddTool(def ToolDef, h Handler) {
68+
s.inner.AddTool(def, h)
69+
}
70+
71+
// Tools returns a snapshot of all registered tools in registration order.
72+
// The returned slice is a copy; callers may safely retain it.
73+
func (s *Server) Tools() []ToolRegistration {
74+
inner := s.inner.Tools()
75+
out := make([]ToolRegistration, len(inner))
76+
for i, t := range inner {
77+
out[i] = ToolRegistration{
78+
ToolDef: t.Def,
79+
Handler: t.Handler,
80+
}
81+
}
82+
return out
83+
}
84+
85+
// Name returns the server name (as reported in InitializeResult serverInfo).
86+
func (s *Server) Name() string { return s.inner.Name() }
87+
88+
// Version returns the server version (as reported in InitializeResult
89+
// serverInfo).
90+
func (s *Server) Version() string { return s.inner.Version() }
91+
92+
// ServeStdio runs the MCP server on os.Stdin / os.Stdout.
93+
func (s *Server) ServeStdio(ctx context.Context) error {
94+
return s.inner.ServeStdio(ctx)
95+
}
96+
97+
// ServeConn runs the MCP server on the given reader/writer. It is the
98+
// testable entry point; production code typically calls [Server.ServeStdio].
99+
func (s *Server) ServeConn(ctx context.Context, r io.Reader, w io.Writer) error {
100+
return s.inner.ServeConn(ctx, r, w)
101+
}
102+
103+
// Inner returns the underlying [mcpstdio.Server]. This is intended for use
104+
// by code within this module that needs direct access; external consumers
105+
// should use [Server.Tools] instead.
106+
func (s *Server) Inner() *mcpstdio.Server {
107+
return s.inner
108+
}

mcpserver/mcpserver_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright © 2026 Phil Pennock.
2+
// All rights reserved, except as granted under license.
3+
// Licensed per file LICENSE.txt
4+
5+
package mcpserver
6+
7+
import (
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"testing"
12+
)
13+
14+
func echoHandler(_ context.Context, args json.RawMessage) (string, error) {
15+
return string(args), nil
16+
}
17+
18+
func failHandler(_ context.Context, _ json.RawMessage) (string, error) {
19+
return "", fmt.Errorf("intentional failure")
20+
}
21+
22+
func TestNewServerAccessors(t *testing.T) {
23+
s := NewServer("test", "v0.1.0")
24+
if s.Name() != "test" {
25+
t.Errorf("Name() = %q, want %q", s.Name(), "test")
26+
}
27+
if s.Version() != "v0.1.0" {
28+
t.Errorf("Version() = %q, want %q", s.Version(), "v0.1.0")
29+
}
30+
if s.Instructions() != "" {
31+
t.Errorf("Instructions() = %q, want empty", s.Instructions())
32+
}
33+
34+
s.SetInstructions("do things")
35+
if s.Instructions() != "do things" {
36+
t.Errorf("Instructions() = %q, want %q", s.Instructions(), "do things")
37+
}
38+
}
39+
40+
func TestToolsEmpty(t *testing.T) {
41+
s := NewServer("test", "v1")
42+
if tools := s.Tools(); len(tools) != 0 {
43+
t.Errorf("Tools() returned %d entries on empty server, want 0", len(tools))
44+
}
45+
}
46+
47+
func TestAddToolAndTools(t *testing.T) {
48+
s := NewServer("test", "v1")
49+
s.AddTool(ToolDef{
50+
Name: "echo",
51+
Description: "echoes arguments",
52+
InputSchema: json.RawMessage(`{"type":"object"}`),
53+
}, echoHandler)
54+
s.AddTool(ToolDef{
55+
Name: "fail",
56+
Description: "always fails",
57+
InputSchema: json.RawMessage(`{"type":"object"}`),
58+
}, failHandler)
59+
60+
tools := s.Tools()
61+
if len(tools) != 2 {
62+
t.Fatalf("Tools() returned %d entries, want 2", len(tools))
63+
}
64+
65+
// Registration order preserved.
66+
if tools[0].Name != "echo" {
67+
t.Errorf("tools[0].Name = %q, want %q", tools[0].Name, "echo")
68+
}
69+
if tools[1].Name != "fail" {
70+
t.Errorf("tools[1].Name = %q, want %q", tools[1].Name, "fail")
71+
}
72+
73+
// Handlers are callable through the bridge type.
74+
result, err := tools[0].Handler(context.Background(), json.RawMessage(`{"x":1}`))
75+
if err != nil {
76+
t.Fatalf("echo handler error: %v", err)
77+
}
78+
if result != `{"x":1}` {
79+
t.Errorf("echo handler result = %q, want %q", result, `{"x":1}`)
80+
}
81+
82+
_, err = tools[1].Handler(context.Background(), nil)
83+
if err == nil {
84+
t.Fatal("fail handler should return error")
85+
}
86+
}
87+
88+
func TestToolsReturnsCopy(t *testing.T) {
89+
s := NewServer("test", "v1")
90+
s.AddTool(ToolDef{Name: "a", Description: "a"}, echoHandler)
91+
92+
t1 := s.Tools()
93+
t2 := s.Tools()
94+
t1[0].Name = "mutated"
95+
if t2[0].Name != "a" {
96+
t.Error("Tools() returned shared backing, not independent copies")
97+
}
98+
}
99+
100+
func TestToolRegistrationFields(t *testing.T) {
101+
s := NewServer("test", "v1")
102+
schema := json.RawMessage(`{"type":"object","properties":{"n":{"type":"string"}}}`)
103+
s.AddTool(ToolDef{
104+
Name: "greet",
105+
Description: "says hello",
106+
InputSchema: schema,
107+
}, echoHandler)
108+
109+
tr := s.Tools()[0]
110+
if tr.Name != "greet" {
111+
t.Errorf("Name = %q", tr.Name)
112+
}
113+
if tr.Description != "says hello" {
114+
t.Errorf("Description = %q", tr.Description)
115+
}
116+
if string(tr.InputSchema) != string(schema) {
117+
t.Errorf("InputSchema = %s", tr.InputSchema)
118+
}
119+
if tr.Handler == nil {
120+
t.Error("Handler is nil")
121+
}
122+
}
123+
124+
func TestInnerNotNil(t *testing.T) {
125+
s := NewServer("test", "v1")
126+
if s.Inner() == nil {
127+
t.Error("Inner() returned nil")
128+
}
129+
}

0 commit comments

Comments
 (0)