Repository navigation
Expand file tree
/
Copy pathserver_test.go
More file actions
483 lines (427 loc) · 17.6 KB
/
Copy pathserver_test.go
File metadata and controls
483 lines (427 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
package mcp
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"testing"
gosdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/LeanerCloud/cloud-commitments-mcp/tools"
)
// TestMain pins tools.EnvAuditLog to a path inside a run-scoped temp
// directory for this package's test binary. Auditing is on by default (see
// tools.EnvAuditLog), and every test in this file calls NewServer, which now
// probes the audit log path at construction time -- without this, the whole
// suite would touch the developer's real ~/.local/state/cudly/mcp-audit.jsonl.
// Tests that need a specific audit configuration override this with their
// own t.Setenv.
func TestMain(m *testing.M) {
auditDir, err := os.MkdirTemp("", "cudly-mcp-server-audit-testmain")
if err != nil {
log.Printf("create MCP server audit test directory: %v", err)
os.Exit(1)
}
if err := os.Setenv(tools.EnvAuditLog, filepath.Join(auditDir, "mcp-audit.jsonl")); err != nil {
log.Printf("set MCP server audit log test path: %v", err)
if cleanupErr := os.RemoveAll(auditDir); cleanupErr != nil {
log.Printf("remove MCP server audit test directory after setup failure: %v", cleanupErr)
}
os.Exit(1)
}
// os.Exit skips deferred calls, so the temp dir is removed explicitly
// before exiting rather than via defer.
code := m.Run()
os.RemoveAll(auditDir)
os.Exit(code)
}
func TestNewServerBuildsWithoutError(t *testing.T) {
t.Parallel()
s, err := NewServer("test")
require.NoError(t, err)
require.NotNil(t, s)
}
// TestNewServerFailsOnBadAuditPath proves NewServer refuses to build when
// the resolved audit log path is unwritable, rather than silently dropping
// every audit record for the session. The path's parent is a regular file
// (not a directory), so descriptor-relative directory preparation fails.
// Not parallel: t.Setenv forbids it.
func TestNewServerFailsOnBadAuditPath(t *testing.T) {
dir := t.TempDir()
blockingFile := filepath.Join(dir, "not-a-directory")
require.NoError(t, os.WriteFile(blockingFile, []byte("x"), 0o600))
t.Setenv(tools.EnvAuditLog, filepath.Join(blockingFile, "nested.jsonl"))
s, err := NewServer("test")
require.Error(t, err)
assert.Nil(t, s)
assert.Contains(t, err.Error(), "audit log")
}
// TestNewServerRejectsNonRegularAuditPath proves startup rejects paths like
// /dev/null before the server accepts MCP traffic and loses every audit line.
// Not parallel: t.Setenv forbids it.
func TestNewServerRejectsNonRegularAuditPath(t *testing.T) {
info, err := os.Stat("/dev/null")
if err != nil {
t.Skipf("/dev/null unavailable: %v", err)
}
if info.Mode().IsRegular() {
t.Skip("/dev/null is a regular file in this environment")
}
t.Setenv(tools.EnvAuditLog, "/dev/null")
s, err := NewServer("test")
require.Error(t, err)
assert.Nil(t, s)
assert.Contains(t, err.Error(), "audit log")
assert.Contains(t, err.Error(), "non-regular audit log target")
}
// TestNewServerSucceedsWhenAuditDisabled proves the audit gate does not
// interfere with server construction when auditing is explicitly disabled.
// Not parallel: t.Setenv forbids it.
func TestNewServerSucceedsWhenAuditDisabled(t *testing.T) {
t.Setenv(tools.EnvAuditLog, "")
s, err := NewServer("test")
require.NoError(t, err)
assert.NotNil(t, s)
}
// TestRegistryNonEmpty proves the tool registry is never accidentally empty:
// cudly_list_commitment_actions is always present, even before any
// purchase/search tool has been registered.
func TestRegistryNonEmpty(t *testing.T) {
t.Parallel()
regs := registrations()
descriptors := make([]tools.Descriptor, 0, len(regs)+1)
for _, r := range regs {
descriptors = append(descriptors, r.Descriptor())
}
listTool := tools.NewListCommitmentActions(descriptors)
descriptors = append(descriptors, listTool.Descriptor())
require.NotEmpty(t, descriptors)
names := make(map[string]bool, len(descriptors))
for _, d := range descriptors {
names[d.Name] = true
}
assert.True(t, names["cudly_list_commitment_actions"])
}
// TestRealPurchaseToolsDocumentMoneyImpactAndDryRun proves every tool that
// can execute a real purchase leads its description with a money-impact
// statement and a dry-run recommendation (design doc §3/§5) -- so any future
// purchase tool that forgets one fails this test rather than shipping a
// tool description that quietly omits the safety framing every other
// purchase tool carries.
// TestEndToEndSearchThenDryRunPurchase drives the real MCP protocol path
// end to end -- a real Client connected to the real NewServer over an
// in-memory transport, not a bare Go function call -- through the exact
// chain the README's worked example describes: list the catalog, then call
// cudly_aws_ec2_ri_purchase with dry_run=true. It proves the tool schema
// registered without error (AddTool's schema inference/validation runs at
// connect time) and that a dry-run purchase returns structured cost JSON
// without any AWS credentials configured in this test environment --
// confirming dry_run=true never reaches the real purchase path even through
// the full protocol stack, not just the Go-level unit tests in
// tools/aws_ec2_ri_test.go.
func TestEndToEndSearchThenDryRunPurchase(t *testing.T) {
t.Parallel()
ctx := context.Background()
server, err := NewServer("test")
require.NoError(t, err)
clientTransport, serverTransport := gosdk.NewInMemoryTransports()
go func() {
_ = server.Run(ctx, serverTransport)
}()
client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
require.NoError(t, err)
defer session.Close()
toolsList, err := session.ListTools(ctx, nil)
require.NoError(t, err)
names := make(map[string]bool, len(toolsList.Tools))
for _, tl := range toolsList.Tools {
names[tl.Name] = true
}
assert.True(t, names["cudly_list_commitment_actions"])
assert.True(t, names["cudly_aws_ec2_ri_purchase"])
result, err := session.CallTool(ctx, &gosdk.CallToolParams{
Name: "cudly_aws_ec2_ri_purchase",
Arguments: map[string]any{
"region": "us-east-1",
"instance_type": "m5.large",
"count": 3,
"term_years": 3,
"payment_option": "no-upfront",
// dry_run/confirm omitted: must default to true/false.
},
})
require.NoError(t, err)
require.False(t, result.IsError, "dry_run purchase must not be a tool error")
structured, err := json.Marshal(result.StructuredContent)
require.NoError(t, err)
var resp tools.PurchaseResponse
require.NoError(t, json.Unmarshal(structured, &resp))
assert.True(t, resp.DryRun)
assert.True(t, resp.Success)
assert.Empty(t, resp.Error)
}
// TestListCommitmentActionsIncludesItself is the regression guard for the
// CodeRabbit finding that NewServer built the descriptors slice passed to
// cudly_list_commitment_actions before appending the list tool itself to
// regs, so the catalog the tool actually returns at runtime never included
// its own entry despite its description claiming to list "every" tool.
// Unlike TestRegistryNonEmpty (which builds its own descriptors slice by
// hand) this drives the real NewServer wiring and calls the live tool over
// the protocol, so it fails if NewServer regresses even if the manual
// helper above stays correct.
func TestListCommitmentActionsIncludesItself(t *testing.T) {
t.Parallel()
ctx := context.Background()
server, err := NewServer("test")
require.NoError(t, err)
clientTransport, serverTransport := gosdk.NewInMemoryTransports()
go func() {
_ = server.Run(ctx, serverTransport)
}()
client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &gosdk.CallToolParams{
Name: "cudly_list_commitment_actions",
Arguments: map[string]any{},
})
require.NoError(t, err)
require.False(t, result.IsError, "cudly_list_commitment_actions must not itself error")
structured, err := json.Marshal(result.StructuredContent)
require.NoError(t, err)
var catalog struct {
Actions []tools.ActionEntry `json:"actions"`
}
require.NoError(t, json.Unmarshal(structured, &catalog))
names := make([]string, 0, len(catalog.Actions))
for _, a := range catalog.Actions {
names = append(names, a.Name)
}
assert.Contains(t, names, "cudly_list_commitment_actions",
"the catalog cudly_list_commitment_actions returns must include its own entry, got: %v", names)
assert.Contains(t, names, "cudly_archera_line_items")
for _, action := range catalog.Actions {
if action.Name == "cudly_archera_line_items" {
assert.Equal(t, "insurance", action.Product)
assert.Equal(t, "line_items", action.Action)
assert.False(t, action.RealPurchaseEnabled)
assert.NotEmpty(t, action.ExamplePrompts)
assert.Contains(t, action.Description, "cudly_archera_comparison")
}
}
}
func TestArcheraListingRootDiscoveryWithoutConfiguration(t *testing.T) {
t.Setenv(tools.EnvArcheraAPIKey, "")
t.Setenv(tools.EnvArcheraOrgID, "")
t.Setenv(tools.EnvArcheraPlanID, "")
ctx := context.Background()
server, err := NewServer("test")
require.NoError(t, err, "discovery must not require a vendor request or credentials")
ct, st := gosdk.NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
require.NoError(t, err)
defer ss.Close()
cs, err := gosdk.NewClient(&gosdk.Implementation{Name: "listing-root-test"}, nil).Connect(ctx, ct, nil)
require.NoError(t, err)
defer cs.Close()
listed, err := cs.ListTools(ctx, nil)
require.NoError(t, err)
var listing *gosdk.Tool
for _, tool := range listed.Tools {
if tool.Name == "cudly_archera_line_items" {
listing = tool
}
}
require.NotNil(t, listing)
require.NotNil(t, listing.Annotations)
assert.True(t, listing.Annotations.ReadOnlyHint)
assert.True(t, *listing.Annotations.OpenWorldHint)
assert.False(t, *listing.Annotations.DestructiveHint)
result, err := cs.CallTool(ctx, &gosdk.CallToolParams{Name: listing.Name, Arguments: map[string]any{}})
require.NoError(t, err)
require.True(t, result.IsError)
assert.Empty(t, result.StructuredContent)
}
func TestRealPurchaseToolsDocumentMoneyImpactAndDryRun(t *testing.T) {
t.Parallel()
for _, r := range registrations() {
d := r.Descriptor()
if !d.RealPurchaseEnabled {
continue
}
lower := strings.ToLower(d.Description)
assert.Truef(t, strings.Contains(lower, "real money") || strings.Contains(lower, "spends money"),
"tool %q description must state its money impact: %q", d.Name, d.Description)
assert.Truef(t, strings.Contains(lower, "dry_run") || strings.Contains(lower, "dry run"),
"tool %q description must recommend dry_run first: %q", d.Name, d.Description)
}
}
// TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront proves the
// cudly_azure_compute_ri_purchase tool's advertised payment_option enum
// never offers partial-upfront: Azure has no partial-upfront billing plan
// (tools/azure_compute_ri.go rejects it at runtime as defense in depth),
// so the schema itself must not invite a caller to pick a value the tool
// can only ever refuse. Drives the real MCP protocol (ListTools), not a
// bare Go function call, so it catches a regression in what a real client
// actually sees, not just in the tool's internal validation.
func TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront(t *testing.T) {
t.Parallel()
ctx := context.Background()
server, err := NewServer("test")
require.NoError(t, err)
clientTransport, serverTransport := gosdk.NewInMemoryTransports()
go func() {
_ = server.Run(ctx, serverTransport)
}()
client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
require.NoError(t, err)
defer session.Close()
toolsList, err := session.ListTools(ctx, nil)
require.NoError(t, err)
var azureTool *gosdk.Tool
for _, tl := range toolsList.Tools {
if tl.Name == "cudly_azure_compute_ri_purchase" {
azureTool = tl
break
}
}
require.NotNil(t, azureTool, "cudly_azure_compute_ri_purchase must be registered")
schema, ok := azureTool.InputSchema.(map[string]any)
require.True(t, ok, "client-side InputSchema must be the default JSON marshaling (map[string]any)")
properties, ok := schema["properties"].(map[string]any)
require.True(t, ok)
paymentOption, ok := properties["payment_option"].(map[string]any)
require.True(t, ok)
enum, ok := paymentOption["enum"].([]any)
require.True(t, ok)
assert.NotContains(t, enum, "partial-upfront",
"Azure schema must not advertise partial-upfront: Azure has no such billing plan")
assert.Contains(t, enum, "all-upfront")
assert.Contains(t, enum, "no-upfront")
}
// TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums pins that
// cudly_search_recommendations tells a client which term_years and
// payment_option values are valid, rather than leaving it to discover them by
// sending a value the tool rejects. ValidateTermYears / ValidatePaymentOption
// remain the enforcing guard (a client may send anything regardless of what
// the schema declares); this covers discoverability, and fails if the schema
// ever drifts from those validators. Drives the real MCP protocol (ListTools)
// so it asserts what an actual client sees.
func TestSearchRecommendationsSchemaAdvertisesTermAndPaymentEnums(t *testing.T) {
t.Parallel()
ctx := context.Background()
server, err := NewServer("test")
require.NoError(t, err)
clientTransport, serverTransport := gosdk.NewInMemoryTransports()
go func() {
_ = server.Run(ctx, serverTransport)
}()
client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
require.NoError(t, err)
defer session.Close()
toolsList, err := session.ListTools(ctx, nil)
require.NoError(t, err)
var searchTool *gosdk.Tool
for _, tl := range toolsList.Tools {
if tl.Name == "cudly_search_recommendations" {
searchTool = tl
break
}
}
require.NotNil(t, searchTool, "cudly_search_recommendations must be registered")
schema, ok := searchTool.InputSchema.(map[string]any)
require.True(t, ok, "client-side InputSchema must be the default JSON marshaling (map[string]any)")
properties, ok := schema["properties"].(map[string]any)
require.True(t, ok)
paymentOption, ok := properties["payment_option"].(map[string]any)
require.True(t, ok)
paymentEnum, ok := paymentOption["enum"].([]any)
require.True(t, ok, "payment_option must advertise its value set")
assert.ElementsMatch(t, []any{"all-upfront", "partial-upfront", "no-upfront"}, paymentEnum)
termYears, ok := properties["term_years"].(map[string]any)
require.True(t, ok)
termEnum, ok := termYears["enum"].([]any)
require.True(t, ok, "term_years must advertise its value set")
// JSON numbers decode as float64 on the client side, so compare on the
// decoded values rather than the ints the server-side override used.
assert.ElementsMatch(t, []any{float64(1), float64(3)}, termEnum)
}
// TestToolCallArgumentsNullOrAbsentApplyDefaults is the regression guard for
// #95: an explicit `"arguments": null` on a tool with schema defaults used to
// panic in the SDK's handler goroutine and kill the server process.
func TestToolCallArgumentsNullOrAbsentApplyDefaults(t *testing.T) {
t.Parallel()
cases := map[string]any{
"null": json.RawMessage("null"),
"omitted": nil,
"empty": map[string]any{},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
server, err := NewServer("test")
require.NoError(t, err)
clientTransport, serverTransport := gosdk.NewInMemoryTransports()
go func() { _ = server.Run(ctx, serverTransport) }()
client := gosdk.NewClient(&gosdk.Implementation{Name: "test-client"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
require.NoError(t, err)
defer session.Close()
result, err := session.CallTool(ctx, &gosdk.CallToolParams{
Name: "cudly_aws_ec2_ri_purchase",
Arguments: args,
})
require.NoError(t, err, "server must stay alive and answer")
require.True(t, result.IsError, "required fields are missing, so this is a tool error, never a purchase")
require.NoError(t, session.Ping(ctx, nil), "server must still be alive after the call")
})
}
}
func TestNullArgumentsAsEmptyObjectMiddleware(t *testing.T) {
t.Parallel()
var got gosdk.Request
called := 0
next := func(_ context.Context, _ string, req gosdk.Request) (gosdk.Result, error) {
got, called = req, called+1
return nil, nil
}
h := nullArgumentsAsEmptyObject(next)
callWith := func(args json.RawMessage) *gosdk.CallToolRequest {
return &gosdk.CallToolRequest{Params: &gosdk.CallToolParamsRaw{Name: "x", Arguments: args}}
}
for _, in := range []string{"null", " null ", "null\n"} {
called = 0
_, err := h(context.Background(), "tools/call", callWith(json.RawMessage(in)))
require.NoError(t, err)
require.Equal(t, 1, called)
assert.Equal(t, []byte("{}"), []byte(got.(*gosdk.CallToolRequest).Params.Arguments), "input %q", in)
}
for _, in := range []json.RawMessage{json.RawMessage(`{"a":1}`), json.RawMessage(`{}`), json.RawMessage(`[]`), nil} {
called = 0
_, err := h(context.Background(), "tools/call", callWith(in))
require.NoError(t, err)
require.Equal(t, 1, called)
assert.Equal(t, in, got.(*gosdk.CallToolRequest).Params.Arguments, "input %q", in)
}
list := &gosdk.ListToolsRequest{}
called = 0
_, err := h(context.Background(), "tools/list", list)
require.NoError(t, err)
assert.Equal(t, 1, called)
assert.Same(t, list, got)
nilParams := &gosdk.CallToolRequest{}
called = 0
require.NotPanics(t, func() { _, err = h(context.Background(), "tools/call", nilParams) })
require.NoError(t, err)
assert.Equal(t, 1, called)
assert.Same(t, nilParams, got)
assert.Nil(t, nilParams.Params)
}