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
7 changes: 7 additions & 0 deletions .sdd/graph/wip/20261106-232933-christopher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
entry: 20261106-232735-d-tac-d3f
participant: Christopher
exclusive: true
---

mcpapp registers SDD's tools onto a host-supplied MCP server (d-tac-d3f)
2 changes: 1 addition & 1 deletion examples/extendingsdd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The composition in `main.go` owns:
- graph, LLM, embedding/index, and finalizer adapters per project, and one session store and one staged-blob store for the whole composition;
- the HTTP listener and deployment policy.

SDD owns graph semantics, workflow procedures, read rendering, pre-flight and summary behavior, durable transition recovery, and write gates. The composition imports the canonical `application` contracts and optional `local` adapters, constructs an `application.ProjectRuntime` per project, resolves them through `application.AccessResolver`, creates `application.Application` with the composition-wide session and staged-blob stores, and mounts `mcpapp.Server.Handler()` behind the MCP SDK's bearer middleware. A session handle is the session ID alone; the application reads a session's home project from its own record, so the server pins no project and serves every project the principal can reach.
SDD owns graph semantics, workflow procedures, read rendering, pre-flight and summary behavior, durable transition recovery, and write gates. The composition imports the canonical `application` contracts and optional `local` adapters, constructs an `application.ProjectRuntime` per project, resolves them through `application.AccessResolver`, creates `application.Application` with the composition-wide session and staged-blob stores, and mounts `mcpapp.Server.Handler()` behind the MCP SDK's bearer middleware. A session handle is the session ID alone; the application reads a session's home project from its own record, so the server pins no project and serves every project the principal can reach. A composition with MCP tools of its own constructs the server itself and adds SDD's tools to it with `mcpapp.Register`.

For local development the module uses `replace github.com/networkteam/sdd => ../..`. A release consumer removes that replacement and pins a published SDD version.

Expand Down
8 changes: 5 additions & 3 deletions pkg/mcpapp/doc.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Package mcpapp adapts the protocol-neutral SDD application to MCP.
//
// New registers the shared tool surface over an application.Application. Handler
// exposes stateless Streamable HTTP so an embedding composition can
// place its own authentication middleware in front. Authentication middleware
// Register adds the shared tool surface over an application.Application to an
// MCP server the host constructs, beside tools of the host's own. New builds a
// server carrying SDD's tools alone; its Handler exposes stateless Streamable
// HTTP so an embedding composition can place its own authentication middleware
// in front. Authentication middleware
// must populate the MCP SDK's TokenInfo on every request; mcpapp translates
// that current request identity into application.RequestIdentity and never treats the
// session's initialization identity as authorization proof.
Expand Down
4 changes: 3 additions & 1 deletion pkg/mcpapp/instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ package mcpapp
// short and front-loads what matters. Tier one is the session shell's opening
// serve (the user-dialogue base procedure); tier two is the per-step units.

const serverInstructions = `This server hosts an SDD (Signal-Dialogue-Decision) graph: an append-only
// Instructions is SDD's server instructions text. A host that registers SDD's
// tools with [Register] includes it in its own server instructions.
const Instructions = `This server hosts an SDD (Signal-Dialogue-Decision) graph: an append-only
record of project observations (signals) and commitments (decisions) grown
through dialogue between the user and their agents.

Expand Down
82 changes: 82 additions & 0 deletions pkg/mcpapp/register_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package mcpapp_test

import (
"context"
"slices"
"strings"
"testing"

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

mcpserver "github.com/networkteam/sdd/pkg/mcpapp"
)

type echoArgs struct {
Text string `json:"text"`
}

type echoResult struct {
Text string `json:"text"`
}

// TestRegisterServesSDDToolsBesideHostTools composes a host-supplied server
// carrying SDD's tools and a host tool, and drives both through one
// connection (d-tac-d3f).
func TestRegisterServesSDDToolsBesideHostTools(t *testing.T) {
env := newTestServer(t, nil, "", "")
host := mcp.NewServer(&mcp.Implementation{Name: "host", Version: "test"}, &mcp.ServerOptions{
Instructions: mcpserver.Instructions + "\n\nhost_echo echoes text.",
})
if err := mcpserver.Register(host, env.opts); err != nil {
t.Fatal(err)
}
mcp.AddTool(host, &mcp.Tool{Name: "host_echo", Description: "Echo text."},
func(_ context.Context, _ *mcp.CallToolRequest, in echoArgs) (*mcp.CallToolResult, echoResult, error) {
return nil, echoResult(in), nil
})
serverTransport, clientTransport := mcp.NewInMemoryTransports()
if _, err := host.Connect(t.Context(), serverTransport, nil); err != nil {
t.Fatal(err)
}
cs := connect(t, nil, clientTransport)

if got := cs.InitializeResult().ServerInfo.Name; got != "host" {
t.Errorf("server name = %q, want the host's", got)
}
if !strings.HasPrefix(cs.InitializeResult().Instructions, mcpserver.Instructions) {
t.Error("host instructions should carry SDD's instructions text")
}
want := append(toolNames(t, connect(t, env.srv)), "host_echo")
slices.Sort(want)
if got := toolNames(t, cs); !slices.Equal(got, want) {
t.Errorf("tools = %v, want SDD's tools plus host_echo %v", got, want)
}

openSession(t, cs)
var echo echoResult
call(t, cs, "host_echo", map[string]any{"text": "hello"}, &echo)
if echo.Text != "hello" {
t.Errorf("host_echo = %q, want hello", echo.Text)
}
}

func TestRegisterRequiresServer(t *testing.T) {
env := newTestServer(t, nil, "", "")
if err := mcpserver.Register(nil, env.opts); err == nil {
t.Fatal("Register without a server should fail")
}
}

func toolNames(t *testing.T, cs *mcp.ClientSession) []string {
t.Helper()
res, err := cs.ListTools(t.Context(), &mcp.ListToolsParams{})
if err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(res.Tools))
for _, tool := range res.Tools {
names = append(names, tool.Name)
}
slices.Sort(names)
return names
}
2 changes: 1 addition & 1 deletion pkg/mcpapp/request_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func publicIdentityFromRequest(req *mcp.CallToolRequest) sdd.RequestIdentity {
return sdd.RequestIdentity{Subject: identity.Subject, Scopes: identity.Scopes, Attributes: identity.Attributes}
}

func (s *Server) requestIdentity(req *mcp.CallToolRequest) sdd.RequestIdentity {
func (s *toolSet) requestIdentity(req *mcp.CallToolRequest) sdd.RequestIdentity {
identity := publicIdentityFromRequest(req)
if identity.Subject == "" && s.localIdentity.Subject != "" {
return s.localIdentity
Expand Down
83 changes: 60 additions & 23 deletions pkg/mcpapp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
sdd "github.com/networkteam/sdd/pkg/application"
)

// Options configures a workflow MCP server.
// Options configures SDD's workflow tools.
type Options struct {
// Application is the protocol-neutral SDD runtime. The server pins no
// project: start_session selects one inside the dialogue, a sole
Expand All @@ -30,13 +30,14 @@ type Options struct {
// (stdio transport). Local clients get absolute paths in read results
// (read_attachment) so they can read files directly instead of paging.
LocalClient bool
Version string
// Version is the SDD version the info tool reports, and the server
// implementation version under New.
Version string
}

// Server wires the MCP protocol surface to the engine and the SDD read and
// write layers.
type Server struct {
mcp *mcp.Server
// toolSet carries what SDD's tool handlers need, independent of the server
// they are registered on.
type toolSet struct {
app *sdd.Application
searchSyncMode sdd.SearchSyncMode
localIdentity sdd.RequestIdentity
Expand All @@ -46,39 +47,75 @@ type Server struct {
// about a dialogue: nothing is keyed to a connection, and no connection
// event acts on a session (d-cpt-aen).
sessions *sessionCache

lifecycleMu sync.Mutex
closing bool
}

// ErrServerClosing reports that a connection or request arrived after
// graceful shutdown began.
var ErrServerClosing = errors.New("mcpapp: server is shutting down")

// New constructs the server and registers the workflow tool surface.
func New(opts Options) (*Server, error) {
func newToolSet(opts Options) (*toolSet, error) {
if !opts.SearchSyncMode.Valid() {
return nil, errors.New("mcpapp: SearchSyncMode is required: none, local, or all")
}
if opts.Application == nil {
return nil, errors.New("mcpapp: Application is required")
}
s := &Server{
return &toolSet{
app: opts.Application,
searchSyncMode: opts.SearchSyncMode,
localIdentity: opts.LocalIdentity,
local: opts.LocalClient,
version: opts.Version,
sessions: newSessionCache(),
}, nil
}

// Register adds SDD's workflow tools to a server the host constructs, beside
// any tools of the host's own (d-tac-d3f). The host owns that server: its
// implementation name and version, its instructions, composed around
// [Instructions], its transport and handler, its authentication, and its
// lifecycle. SDD's tools derive nothing from the connection, so any transport
// serves them.
//
// The MCP SDK replaces a tool registered under an existing name without error:
// a host tool must not reuse one of SDD's tool names.
func Register(server *mcp.Server, opts Options) error {
if server == nil {
return errors.New("mcpapp: server is required")
}
tools, err := newToolSet(opts)
if err != nil {
return err
}
tools.register(server)
return nil
}

// Server is an MCP server carrying SDD's tools alone, for compositions that
// add no tools of their own. It owns the transport and the lifecycle.
type Server struct {
mcp *mcp.Server

lifecycleMu sync.Mutex
closing bool
}

// ErrServerClosing reports that a connection or request arrived after
// graceful shutdown began.
var ErrServerClosing = errors.New("mcpapp: server is shutting down")

// New constructs a server named sdd and registers SDD's tools on it.
func New(opts Options) (*Server, error) {
tools, err := newToolSet(opts)
if err != nil {
return nil, err
}
s := &Server{
mcp: mcp.NewServer(&mcp.Implementation{
Name: "sdd",
Version: opts.Version,
}, &mcp.ServerOptions{
Instructions: Instructions,
}),
}
s.mcp = mcp.NewServer(&mcp.Implementation{
Name: "sdd",
Version: opts.Version,
}, &mcp.ServerOptions{
Instructions: serverInstructions,
})
s.mcp.AddReceivingMiddleware(s.refuseWhileClosing)
s.registerTools()
tools.register(s.mcp)
return s, nil
}

Expand Down
3 changes: 2 additions & 1 deletion pkg/mcpapp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ func writeFixtureGraph(t *testing.T) string {

type testEnv struct {
srv *mcpserver.Server
opts mcpserver.Options
graphDir string
sessionsDir string
sessions sdd.SessionStore
Expand Down Expand Up @@ -414,7 +415,7 @@ func newTestServerConfig(t *testing.T, findings []query.Finding, graphDir, sessi
t.Errorf("shutting down MCP server: %v", err)
}
})
return testEnv{srv: srv, graphDir: graphDir, sessionsDir: sessionsDir, sessions: opts.Application.Sessions, targets: targets}
return testEnv{srv: srv, opts: opts.MCP, graphDir: graphDir, sessionsDir: sessionsDir, sessions: opts.Application.Sessions, targets: targets}
}

// connect uses an in-memory connection unless the caller supplies a transport.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func TestShutdownContextBoundsInFlightSessionDrain(t *testing.T) {
}

func newLifecycleTestServer() *Server {
server := &Server{sessions: newSessionCache()}
server := &Server{}
server.mcp = mcp.NewServer(&mcp.Implementation{Name: "lifecycle-test", Version: "test"}, nil)
server.mcp.AddReceivingMiddleware(server.refuseWhileClosing)
return server
Expand Down
Loading
Loading