Editor MCP assistant tools for services and social features. Provides commands to manage Cloud Database tables (create/get/list records, group info), inspect and unlock Achievements, query Steam friends via reflection, and run Voice component diagnostics in the active scene.
#nullable enable
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Sandbox;
using Sandbox.Services;
namespace Editor.Mcp
{
public static partial class SboxMcpAssistant
{
/// <summary>
/// Manages and queries modern s&box Facepunch Cloud Database tables (Sandbox.Services.Database)
/// for player inventories, match persistence, UGC, and custom cloud save states.
/// </summary>
[McpTool( "pocketknife_database_manage" )]
public static async Task<string> DatabaseManage(
[Description( "Action: 'get_table', 'create_record', 'get_record', 'list_records', 'list_owned', 'group_info'" )] string action,
[Description( "Database table name (e.g. 'player_stats', 'inventory', 'match_logs')" )] string table = "player_stats",
[Description( "Record key (for create_record)" )] string? key = null,
[Description( "Record string or JSON payload value" )] string? value = null,
[Description( "Grouping identifier (e.g. 'global', 'season_1', 'user')" )] string group = "global",
[Description( "Whether record is publicly readable" )] bool isPublic = true,
[Description( "Record ID (for get_record)" )] long id = 0,
[Description( "Pagination cursor (for list_records)" )] long cursor = 0,
[Description( "Page size (1-100, default 20)" )] int pageSize = 20 )
{
try
{
var normAction = action.ToLowerInvariant().Trim();
var dbTable = Database.GetTable( table );
if ( dbTable == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Table = table }, ToolStatus.Failed, "database.table_unavailable", $"Database table '{table}' is unavailable." ) );
}
if ( normAction == "get_table" || normAction == "status" )
{
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = normAction,
dbTable.Name,
dbTable.Package
} ) );
}
if ( normAction == "create_record" )
{
if ( string.IsNullOrWhiteSpace( key ) )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "database.missing_key", "Key is required to create a record." ) );
}
var record = await dbTable.Create( key, value ?? string.Empty, group, isPublic );
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = "create_record",
Table = table,
RecordId = record.Id,
record.Key,
record.Value,
record.Group,
record.IsPublic,
record.CreatedAt,
record.UpdatedAt,
record.Revision
} ) );
}
if ( normAction == "get_record" )
{
var record = await dbTable.Get( id );
if ( record == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Id = id }, ToolStatus.Degraded, "database.not_found", $"Record #{id} not found." ) );
}
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = "get_record",
Table = table,
RecordId = record.Id,
record.Key,
record.Value,
record.Group,
record.IsPublic,
record.CreatedAt,
record.UpdatedAt,
record.Revision
} ) );
}
if ( normAction == "list_records" )
{
var boundedPageSize = Math.Clamp( pageSize, 1, 100 );
var page = await dbTable.List( group, cursor, boundedPageSize );
var records = page.Records?.Select( r => new
{
RecordId = r.Id,
r.Key,
r.Value,
r.Group,
r.IsPublic,
r.CreatedAt,
r.UpdatedAt,
r.Revision
} ).ToArray() ?? Array.Empty<object>();
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = "list_records",
Table = table,
Group = group,
NextCursor = page.NextCursor,
ReturnedCount = records.Length,
Records = records
} ) );
}
if ( normAction == "list_owned" )
{
var boundedPageSize = Math.Clamp( pageSize, 1, 100 );
var page = await dbTable.ListOwned( group, cursor, boundedPageSize );
var records = page.Records?.Select( r => new
{
RecordId = r.Id,
r.Key,
r.Value,
r.Group,
r.IsPublic,
r.CreatedAt,
r.UpdatedAt,
r.Revision
} ).ToArray() ?? Array.Empty<object>();
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = "list_owned",
Table = table,
Group = group,
NextCursor = page.NextCursor,
ReturnedCount = records.Length,
Records = records
} ) );
}
if ( normAction == "group_info" )
{
var groups = new[] { group };
var infoMap = await dbTable.GetPublicGroupInfo( groups );
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = "group_info",
Table = table,
Groups = infoMap
} ) );
}
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "database.invalid_action", $"Unsupported database action '{action}'. Use 'status', 'get_table', 'create_record', 'get_record', 'list_records', 'list_owned', or 'group_info'." ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "database.operation_failed", "Database operation failed.", ex ) );
}
}
/// <summary>
/// Inspects and unlocks s&box achievements (Sandbox.Services.Achievements) to test reward logic,
/// milestones, score progression, and trophy pipelines during gameplay simulation.
/// </summary>
[McpTool( "pocketknife_achievement_manage" )]
public static string AchievementManage(
[Description( "Action: 'list', 'unlock', 'status'" )] string action = "list",
[Description( "Achievement identifier name (e.g. 'first_blood', 'win_10_matches')" )] string? name = null )
{
try
{
var normAction = action.ToLowerInvariant().Trim();
var allAchievements = Achievements.All?.ToArray() ?? Array.Empty<Achievement>();
if ( normAction == "list" )
{
var list = allAchievements.Select( a => new
{
a.Name,
a.Title,
a.Description,
a.IsUnlocked,
a.Score,
a.CurrentValue,
a.HasProgression,
a.ProgressionFraction,
a.GlobalFraction
} ).ToArray();
return McpJson.Write( ToolOutcome<object>.Ready( new
{
TotalCount = list.Length,
UnlockedCount = list.Count( a => a.IsUnlocked ),
Achievements = list
} ) );
}
if ( normAction == "unlock" )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.missing_name", "Achievement name is required to unlock." ) );
}
Achievements.Unlock( name.Trim() );
var target = allAchievements.FirstOrDefault( a => string.Equals( a.Name, name.Trim(), StringComparison.OrdinalIgnoreCase ) );
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = "unlock",
Target = name,
Title = target?.Title,
Unlocked = true
} ) );
}
if ( normAction == "status" )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.missing_name", "Achievement name is required." ) );
}
var target = allAchievements.FirstOrDefault( a => string.Equals( a.Name, name.Trim(), StringComparison.OrdinalIgnoreCase ) );
if ( target == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( new { Name = name }, ToolStatus.Degraded, "achievement.not_found", $"Achievement '{name}' was not found in project definition." ) );
}
return McpJson.Write( ToolOutcome<object>.Ready( new
{
target.Name,
target.Title,
target.Description,
target.IsUnlocked,
target.Score,
target.CurrentValue,
target.HasProgression,
target.ProgressionFraction,
target.GlobalFraction,
target.UnlockTimestamp
} ) );
}
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.unknown_action", $"Unknown achievement action '{action}'." ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "achievement.manage_failed", "Achievement operation failed.", ex ) );
}
}
/// <summary>
/// Inspects Steam friends, rich presence status, persona states, and party matchmaking
/// via engine reflection for testing co-op lobbies, join requests, and invite flows.
/// </summary>
[McpTool( "pocketknife_social_manage" )]
public static string SocialManage(
[Description( "Action: 'list_friends', 'status'" )] string action = "list_friends",
[Description( "Filter: 'all', 'online', 'playing_this_game'" )] string filter = "all",
[Description( "Maximum friends to return (1-100, default 25)" )] int limit = 25 )
{
try
{
var normAction = action.ToLowerInvariant().Trim();
var normFilter = filter.ToLowerInvariant().Trim();
var friendsType = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany( a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } } )
.FirstOrDefault( t => t.FullName == "Steamworks.SteamFriends" );
var getFriendsMethod = friendsType?.GetMethod( "GetFriends", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static );
var rawFriends = getFriendsMethod?.Invoke( null, null ) as IEnumerable;
var parsedFriends = new List<object>();
if ( rawFriends != null )
{
foreach ( var f in rawFriends )
{
if ( f == null ) continue;
var fType = f.GetType();
var name = fType.GetProperty( "Name" )?.GetValue( f )?.ToString() ?? "Unknown";
var displayName = fType.GetProperty( "DisplayName" )?.GetValue( f )?.ToString() ?? name;
var isOnline = (bool)(fType.GetProperty( "IsOnline" )?.GetValue( f ) ?? false);
var isPlayingThisGame = (bool)(fType.GetProperty( "IsPlayingThisGame" )?.GetValue( f ) ?? false);
var isPlaying = (bool)(fType.GetProperty( "IsPlaying" )?.GetValue( f ) ?? false);
var isAway = (bool)(fType.GetProperty( "IsAway" )?.GetValue( f ) ?? false);
var isBusy = (bool)(fType.GetProperty( "IsBusy" )?.GetValue( f ) ?? false);
var state = fType.GetProperty( "State" )?.GetValue( f )?.ToString() ?? "Offline";
var steamLevel = (int)(fType.GetProperty( "SteamLevel" )?.GetValue( f ) ?? 0);
var rpMethod = fType.GetMethod( "GetRichPresence", new[] { typeof( string ) } );
var richPresence = rpMethod?.Invoke( f, new object[] { "status" } )?.ToString();
if ( normFilter == "online" && !isOnline ) continue;
if ( normFilter == "playing_this_game" && !isPlayingThisGame ) continue;
parsedFriends.Add( new
{
Name = name,
DisplayName = displayName,
IsOnline = isOnline,
IsPlayingThisGame = isPlayingThisGame,
IsPlaying = isPlaying,
IsAway = isAway,
IsBusy = isBusy,
State = state,
SteamLevel = steamLevel,
RichPresence = richPresence
} );
}
}
var boundedLimit = Math.Clamp( limit, 1, 100 );
var results = parsedFriends.Take( boundedLimit ).ToArray();
return McpJson.Write( ToolOutcome<object>.Ready( new
{
Action = normAction,
Filter = normFilter,
TotalAvailable = parsedFriends.Count,
ReturnedCount = results.Length,
Friends = results
} ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "social.manage_failed", "Social management operation failed.", ex ) );
}
}
/// <summary>
/// Diagnostics and spatial verification for active VoIP Voice components (Sandbox.Voice) in the scene,
/// inspecting worldspace 3D spatialization, volume, lip-sync visemes, laughter score, and audio mixers.
/// </summary>
[McpTool( "pocketknife_voice_diagnostics" )]
public static string VoiceDiagnostics(
[Description( "Target GameObject name with Voice component (optional, scans all if omitted)" )] string? target = null )
{
try
{
var scene = ResolveActiveScene();
if ( scene == null )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "voice.no_scene", "No active scene loaded." ) );
}
var voiceComponents = scene.GetAllComponents<Sandbox.Voice>().ToArray();
if ( !string.IsNullOrWhiteSpace( target ) )
{
voiceComponents = voiceComponents.Where( v => string.Equals( v.GameObject.Name, target.Trim(), StringComparison.OrdinalIgnoreCase ) ).ToArray();
}
var results = voiceComponents.Select( v => new
{
Target = v.GameObject.Name,
TargetId = v.GameObject.Id.ToString(),
Position = v.WorldPosition,
v.Volume,
Mode = v.Mode.ToString(),
v.WorldspacePlayback,
v.Loopback,
v.LipSync,
v.Distance,
v.Amplitude,
v.LaughterScore,
v.IsRecording,
v.IsListening,
VisemeCount = v.Visemes?.Count ?? 0
} ).ToArray();
return McpJson.Write( ToolOutcome<object>.Ready( new
{
TotalVoiceComponents = results.Length,
Voices = results
} ) );
}
catch ( Exception ex )
{
return McpJson.Write( ToolOutcome<object>.Failure( null, ToolStatus.Failed, "voice.diagnostics_failed", "Voice diagnostics failed.", ex ) );
}
}
}
}