Skip to content
Draft
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
99 changes: 99 additions & 0 deletions dotnet/test/Unit/CatalogueConformanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using GitHub.Copilot.Rpc;
using Xunit;

#pragma warning disable GHCP001 // The catalogue search schema is experimental in CLI 1.0.83-2.

namespace GitHub.Copilot.Test.Unit;

public class CatalogueConformanceTests
{
private const string OpaqueMcpHandle = "opaque:mcp/01-do-not-parse";
private const string OpaqueSkillHandle = "opaque:skill/02-do-not-parse";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};

[Fact]
public void CatalogSearchResult_PreservesTypedCandidatesAndOpaqueHandles()
{
const string json = """
{
"kind": "succeeded",
"searchId": "search-01",
"candidates": [
{
"kind": "mcp-server",
"handle": "opaque:mcp/01-do-not-parse",
"handleExpiresAt": "2026-09-02T12:00:00Z",
"mediaType": "application/mcp-server-card+json",
"installability": "installable",
"displayName": "Example MCP",
"rawCard": { "secret": "must-not-survive" },
"source": { "kind": "url", "url": "https://catalog.example/mcp.json" },
"provenance": {
"authority": "catalog.example",
"observedAt": "2026-09-02T11:00:00Z",
"mediaType": "application/mcp-server-card+json"
}
},
{
"kind": "ai-skill",
"handle": "opaque:skill/02-do-not-parse",
"handleExpiresAt": "2026-09-02T12:00:00Z",
"mediaType": "application/ai-skill",
"installability": "not-installable-kind",
"displayName": "Example skill",
"rawCard": { "secret": "must-not-survive" },
"source": { "kind": "embedded" },
"provenance": {
"authority": "catalog.example",
"observedAt": "2026-09-02T11:00:00Z",
"mediaType": "application/ai-skill"
}
}
],
"truncated": false,
"negotiated": {
"runtimeProtocolVersion": 1,
"grantedCapabilities": ["mcp-server-card", "ai-skill-discovery"]
}
}
""";

var result = Assert.IsType<CatalogSearchResultSucceeded>(
JsonSerializer.Deserialize<CatalogSearchResult>(json, SerializerOptions));
var mcp = Assert.IsType<CatalogCandidateMcpServer>(result.Candidates[0]);
var skill = Assert.IsType<CatalogCandidateAiSkill>(result.Candidates[1]);
Assert.Equal(OpaqueMcpHandle, mcp.Handle);
Assert.Equal(OpaqueSkillHandle, skill.Handle);
Assert.IsType<CatalogCandidateSourceUrl>(mcp.Source);
Assert.IsType<CatalogCandidateSourceEmbedded>(skill.Source);

using var encoded = JsonDocument.Parse(JsonSerializer.Serialize<CatalogSearchResult>(
result, SerializerOptions));
foreach (var candidate in encoded.RootElement.GetProperty("candidates").EnumerateArray())
{
Assert.False(candidate.TryGetProperty("card", out _));
Assert.False(candidate.TryGetProperty("cardData", out _));
Assert.False(candidate.TryGetProperty("rawCard", out _));
}
}

[Fact]
public void CatalogSearchResult_PreservesRefusalsAndFailures()
{
var authentication = JsonSerializer.Deserialize<CatalogSearchResult>(
"""{"kind":"authentication-required","reason":"no-credential","message":"Sign in is required."}""",
SerializerOptions);
Assert.IsType<CatalogSearchResultAuthenticationRequired>(authentication);

var network = Assert.IsType<CatalogSearchResultNetworkFailure>(
JsonSerializer.Deserialize<CatalogSearchResult>(
"""{"kind":"network-failure","reason":"timeout","retryAfterSeconds":30,"message":"The catalogue timed out."}""",
SerializerOptions));
Assert.Equal(30, network.RetryAfterSeconds);
}
}
155 changes: 155 additions & 0 deletions go/rpc/catalogue_conformance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package rpc

import (
"encoding/json"
"testing"
)

const (
opaqueMCPHandle = "opaque:mcp/01-do-not-parse"
opaqueSkillHandle = "opaque:skill/02-do-not-parse"
)

func TestCatalogSearchResultPreservesCandidateSemantics(t *testing.T) {
result, err := unmarshalCatalogSearchResult([]byte(`{
"kind":"succeeded",
"searchId":"search-01",
"candidates":[
{
"kind":"mcp-server",
"handle":"opaque:mcp/01-do-not-parse",
"handleExpiresAt":"2026-09-02T12:00:00Z",
"mediaType":"application/mcp-server-card+json",
"installability":"installable",
"displayName":"Example MCP",
"rawCard":{"secret":"must-not-survive"},
"source":{"kind":"url","url":"https://catalog.example/mcp.json"},
"provenance":{
"authority":"catalog.example",
"observedAt":"2026-09-02T11:00:00Z",
"mediaType":"application/mcp-server-card+json"
}
},
{
"kind":"ai-skill",
"handle":"opaque:skill/02-do-not-parse",
"handleExpiresAt":"2026-09-02T12:00:00Z",
"mediaType":"application/ai-skill",
"installability":"not-installable-kind",
"displayName":"Example skill",
"rawCard":{"secret":"must-not-survive"},
"source":{"kind":"embedded"},
"provenance":{
"authority":"catalog.example",
"observedAt":"2026-09-02T11:00:00Z",
"mediaType":"application/ai-skill"
}
}
],
"truncated":false,
"negotiated":{
"runtimeProtocolVersion":1,
"grantedCapabilities":["mcp-server-card","ai-skill-discovery"]
}
}`))
if err != nil {
t.Fatalf("unmarshal catalogue success: %v", err)
}

success, ok := result.(*CatalogSearchSucceeded)
if !ok {
t.Fatalf("catalogue result = %T, want *CatalogSearchSucceeded", result)
}
mcp, ok := success.Candidates[0].(*CatalogMCPServerCandidate)
if !ok {
t.Fatalf("first candidate = %T, want *CatalogMCPServerCandidate", success.Candidates[0])
}
skill, ok := success.Candidates[1].(*CatalogAiSkillCandidate)
if !ok {
t.Fatalf("second candidate = %T, want *CatalogAiSkillCandidate", success.Candidates[1])
}
if mcp.Handle != opaqueMCPHandle || skill.Handle != opaqueSkillHandle {
t.Fatalf("opaque handles changed: %q, %q", mcp.Handle, skill.Handle)
}
if _, ok := mcp.Source.(*CatalogCandidateSourceURL); !ok {
t.Fatalf("MCP source = %T, want *CatalogCandidateSourceURL", mcp.Source)
}
if _, ok := skill.Source.(*CatalogCandidateSourceEmbedded); !ok {
t.Fatalf("skill source = %T, want *CatalogCandidateSourceEmbedded", skill.Source)
}

encoded, err := json.Marshal(success)
if err != nil {
t.Fatalf("marshal catalogue success: %v", err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatalf("decode catalogue wire result: %v", err)
}
for _, candidate := range wire["candidates"].([]any) {
fields := candidate.(map[string]any)
for _, forbidden := range []string{"card", "cardData", "rawCard"} {
if _, exists := fields[forbidden]; exists {
t.Fatalf("candidate leaked %q: %s", forbidden, encoded)
}
}
Comment thread
gokhanarkan marked this conversation as resolved.
}
}

func TestCatalogSearchResultPreservesRefusalsAndFailures(t *testing.T) {
tests := []struct {
name string
payload string
assert func(*testing.T, CatalogSearchResult)
}{
{
name: "authentication required",
payload: `{"kind":"authentication-required","reason":"no-credential","message":"Sign in is required."}`,
assert: func(t *testing.T, result CatalogSearchResult) {
if _, ok := result.(*CatalogAuthenticationRequiredError); !ok {
t.Fatalf("result = %T, want *CatalogAuthenticationRequiredError", result)
}
},
},
{
name: "network failure",
payload: `{"kind":"network-failure","reason":"timeout","retryAfterSeconds":30,"message":"The catalogue timed out."}`,
assert: func(t *testing.T, result CatalogSearchResult) {
failure, ok := result.(*CatalogNetworkFailureError)
if !ok {
t.Fatalf("result = %T, want *CatalogNetworkFailureError", result)
}
if failure.RetryAfterSeconds == nil || *failure.RetryAfterSeconds != 30 {
t.Fatalf("retryAfterSeconds = %v, want 30", failure.RetryAfterSeconds)
}
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := unmarshalCatalogSearchResult([]byte(test.payload))
if err != nil {
t.Fatalf("unmarshal catalogue result: %v", err)
}
test.assert(t, result)
})
}
}

func TestCatalogSearchResultRejectsUnknownCandidateKinds(t *testing.T) {
_, err := unmarshalCatalogSearchResult([]byte(`{
"kind":"succeeded",
"searchId":"search-unknown",
"candidates":[{
"kind":"future-kind",
"handle":"opaque:future/03-do-not-parse",
"rawCard":{"secret":"must-not-survive"}
}],
"truncated":false,
"negotiated":{"runtimeProtocolVersion":1,"grantedCapabilities":[]}
}`))
if err == nil {
t.Fatal("unknown catalogue candidate kind with rawCard must be rejected")
}
}
10 changes: 0 additions & 10 deletions go/rpc/zrpc.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 1 addition & 13 deletions go/rpc/zrpc_encoding.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading