Skip to content

Commit a0ddfa5

Browse files
MacAttakclaude
andcommitted
fix(resources): validate URI template params and strengthen schema tests
Add identifier validation to extractURIParams — rejects non-identifier strings like code injection or path traversal. Strengthen schema negative tests to assert error messages reference the specific failing field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fdffdcb commit a0ddfa5

File tree

3 files changed

+103
-13
lines changed

3 files changed

+103
-13
lines changed

internal/codegen/mcp_typescript.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,22 @@ import (
1515
// uriParamRe matches {param} placeholders in a URI template.
1616
var uriParamRe = regexp.MustCompile(`\{([^}]+)\}`)
1717

18+
// validIdentifier matches safe JavaScript/TypeScript identifier names.
19+
var validIdentifier = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
20+
1821
// extractURIParams returns the list of parameter names found in a URI template.
19-
func extractURIParams(uri string) []string {
22+
// Returns an error if any parameter name is not a valid identifier.
23+
func extractURIParams(uri string) ([]string, error) {
2024
matches := uriParamRe.FindAllStringSubmatch(uri, -1)
2125
params := make([]string, 0, len(matches))
2226
for _, m := range matches {
23-
params = append(params, m[1])
27+
name := m[1]
28+
if !validIdentifier.MatchString(name) {
29+
return nil, fmt.Errorf("URI template parameter %q is not a valid identifier", name)
30+
}
31+
params = append(params, name)
2432
}
25-
return params
33+
return params, nil
2634
}
2735

2836
// TSMCPGenerator generates TypeScript MCP server projects.
@@ -90,13 +98,17 @@ func (g *TSMCPGenerator) Generate(ctx context.Context, data TemplateData, _ stri
9098
if mimeType == "" {
9199
mimeType = "text/plain"
92100
}
101+
uriParams, uriErr := extractURIParams(res.URI)
102+
if uriErr != nil {
103+
return nil, fmt.Errorf("resource %q: %w", res.Name, uriErr)
104+
}
93105
resData := tsResourceData{
94106
Name: res.Name,
95107
Description: res.Description,
96108
URI: res.URI,
97109
MimeType: mimeType,
98110
Entrypoint: res.Entrypoint,
99-
URIParams: extractURIParams(res.URI),
111+
URIParams: uriParams,
100112
}
101113
var resFile []byte
102114
resFile, err = renderTSTemplate("resource.ts", tsResourceTmpl, resData)

internal/codegen/mcp_typescript_resource_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package codegen
22

33
import (
4+
"context"
45
"strings"
56
"testing"
67

@@ -796,6 +797,65 @@ func TestTSMCP_Resource_IndexRegistersViaImportedFunction(t *testing.T) {
796797
hasImport, hasRegistration)
797798
}
798799

800+
// ---------------------------------------------------------------------------
801+
// URI template parameter validation
802+
// ---------------------------------------------------------------------------
803+
804+
func TestTSMCP_Resource_InvalidURIParam_RejectsInjection(t *testing.T) {
805+
// A URI with a parameter containing code injection must be rejected.
806+
m := mcpManifestSingleResource()
807+
m.Resources[0].URI = `file://{"; process.exit(1);//}`
808+
gen := NewTSMCPGenerator()
809+
data := TemplateData{Manifest: m, Timestamp: "2026-03-09T00:00:00Z", Version: "0.1.0"}
810+
_, err := gen.Generate(context.Background(), data, "")
811+
require.Error(t, err)
812+
assert.Contains(t, err.Error(), "not a valid identifier",
813+
"must reject URI param that is not a safe identifier")
814+
}
815+
816+
func TestTSMCP_Resource_InvalidURIParam_RejectsSpaces(t *testing.T) {
817+
m := mcpManifestSingleResource()
818+
m.Resources[0].URI = `file:///{bad param}`
819+
gen := NewTSMCPGenerator()
820+
data := TemplateData{Manifest: m, Timestamp: "2026-03-09T00:00:00Z", Version: "0.1.0"}
821+
_, err := gen.Generate(context.Background(), data, "")
822+
require.Error(t, err)
823+
assert.Contains(t, err.Error(), "not a valid identifier")
824+
}
825+
826+
func TestTSMCP_Resource_InvalidURIParam_RejectsDotPath(t *testing.T) {
827+
m := mcpManifestSingleResource()
828+
m.Resources[0].URI = `file:///{../etc/passwd}`
829+
gen := NewTSMCPGenerator()
830+
data := TemplateData{Manifest: m, Timestamp: "2026-03-09T00:00:00Z", Version: "0.1.0"}
831+
_, err := gen.Generate(context.Background(), data, "")
832+
require.Error(t, err)
833+
assert.Contains(t, err.Error(), "not a valid identifier")
834+
}
835+
836+
func TestTSMCP_Resource_ValidURIParam_AcceptsUnderscore(t *testing.T) {
837+
m := mcpManifestSingleResource()
838+
m.Resources[0].URI = `file:///{_path}`
839+
gen := NewTSMCPGenerator()
840+
data := TemplateData{Manifest: m, Timestamp: "2026-03-09T00:00:00Z", Version: "0.1.0"}
841+
files, err := gen.Generate(context.Background(), data, "")
842+
require.NoError(t, err)
843+
content := fileContent(t, files, "src/resources/file_reader.ts")
844+
assert.Contains(t, content, "_path")
845+
}
846+
847+
func TestTSMCP_Resource_ValidURIParam_AcceptsAlphanumeric(t *testing.T) {
848+
m := mcpManifestSingleResource()
849+
m.Resources[0].URI = `github://{owner123}/{repo456}`
850+
gen := NewTSMCPGenerator()
851+
data := TemplateData{Manifest: m, Timestamp: "2026-03-09T00:00:00Z", Version: "0.1.0"}
852+
files, err := gen.Generate(context.Background(), data, "")
853+
require.NoError(t, err)
854+
content := fileContent(t, files, "src/resources/file_reader.ts")
855+
assert.Contains(t, content, "owner123")
856+
assert.Contains(t, content, "repo456")
857+
}
858+
799859
// ---------------------------------------------------------------------------
800860
// Resource handler is not placed in the tools directory
801861
// ---------------------------------------------------------------------------

schema_resource_test.go

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -412,8 +412,10 @@ func TestSchemaResource_MissingURI_Fails(t *testing.T) {
412412
"entrypoint": "./resources/test.sh"
413413
}]`)
414414
err := validateJSON(t, manifest)
415-
assert.Error(t, err,
415+
require.Error(t, err,
416416
"resource missing 'uri' must fail schema validation")
417+
assert.Contains(t, err.Error(), "uri",
418+
"error must reference the missing 'uri' field")
417419
}
418420

419421
func TestSchemaResource_MissingName_Fails(t *testing.T) {
@@ -424,8 +426,10 @@ func TestSchemaResource_MissingName_Fails(t *testing.T) {
424426
"entrypoint": "./resources/test.sh"
425427
}]`)
426428
err := validateJSON(t, manifest)
427-
assert.Error(t, err,
429+
require.Error(t, err,
428430
"resource missing 'name' must fail schema validation")
431+
assert.Contains(t, err.Error(), "name",
432+
"error must reference the missing 'name' field")
429433
}
430434

431435
func TestSchemaResource_MissingEntrypoint_Fails(t *testing.T) {
@@ -436,8 +440,10 @@ func TestSchemaResource_MissingEntrypoint_Fails(t *testing.T) {
436440
"description": "Missing entrypoint"
437441
}]`)
438442
err := validateJSON(t, manifest)
439-
assert.Error(t, err,
443+
require.Error(t, err,
440444
"resource missing 'entrypoint' must fail schema validation")
445+
assert.Contains(t, err.Error(), "entrypoint",
446+
"error must reference the missing 'entrypoint' field")
441447
}
442448

443449
func TestSchemaResource_MissingAllRequired_Fails(t *testing.T) {
@@ -523,8 +529,10 @@ func TestSchemaResource_URIWrongType_Fails(t *testing.T) {
523529
"entrypoint": "./resources/test.sh"
524530
}]`)
525531
err := validateJSON(t, manifest)
526-
assert.Error(t, err,
532+
require.Error(t, err,
527533
"resource with uri as %s must fail schema validation", tc.name)
534+
assert.Contains(t, err.Error(), "uri",
535+
"error must reference the 'uri' field")
528536
})
529537
}
530538
}
@@ -548,8 +556,10 @@ func TestSchemaResource_NameWrongType_Fails(t *testing.T) {
548556
"entrypoint": "./resources/test.sh"
549557
}]`)
550558
err := validateJSON(t, manifest)
551-
assert.Error(t, err,
559+
require.Error(t, err,
552560
"resource with name as %s must fail schema validation", tc.name)
561+
assert.Contains(t, err.Error(), "name",
562+
"error must reference the 'name' field")
553563
})
554564
}
555565
}
@@ -573,8 +583,10 @@ func TestSchemaResource_EntrypointWrongType_Fails(t *testing.T) {
573583
"entrypoint": ` + tc.value + `
574584
}]`)
575585
err := validateJSON(t, manifest)
576-
assert.Error(t, err,
586+
require.Error(t, err,
577587
"resource with entrypoint as %s must fail schema validation", tc.name)
588+
assert.Contains(t, err.Error(), "entrypoint",
589+
"error must reference the 'entrypoint' field")
578590
})
579591
}
580592
}
@@ -680,37 +692,43 @@ func TestSchemaResource_EachRequiredFieldMissing_Individually(t *testing.T) {
680692
// Table-driven: remove one required field at a time. Each must fail independently.
681693
// This catches schemas that only enforce some required fields.
682694
tests := []struct {
683-
name string
684-
resource string
695+
name string
696+
resource string
697+
wantField string
685698
}{
686699
{
687700
name: "missing uri only",
688701
resource: `{
689702
"name": "no-uri",
690703
"entrypoint": "./resources/test.sh"
691704
}`,
705+
wantField: "uri",
692706
},
693707
{
694708
name: "missing name only",
695709
resource: `{
696710
"uri": "file:///data/item",
697711
"entrypoint": "./resources/test.sh"
698712
}`,
713+
wantField: "name",
699714
},
700715
{
701716
name: "missing entrypoint only",
702717
resource: `{
703718
"uri": "file:///data/item",
704719
"name": "no-ep"
705720
}`,
721+
wantField: "entrypoint",
706722
},
707723
}
708724
for _, tc := range tests {
709725
t.Run(tc.name, func(t *testing.T) {
710726
manifest := minimalManifestWithResources(`[` + tc.resource + `]`)
711727
err := validateJSON(t, manifest)
712-
assert.Error(t, err,
728+
require.Error(t, err,
713729
"resource with %s must fail schema validation", tc.name)
730+
assert.Contains(t, err.Error(), tc.wantField,
731+
"error must reference the missing %q field", tc.wantField)
714732
})
715733
}
716734
}

0 commit comments

Comments
 (0)