Editor/LibraryTools.cs

MCP toolset for the s&box editor that exposes library management and publish operations to the MCP server. It lists published versions, installs/uninstalls libraries in Libraries/, and runs the Publish wizard steps (compile, gather files, upload, publish).

NetworkingFile Access
using Editor;
using Editor.Mcp;
using Sandbox;
using System.Linq;
using System.Threading.Tasks;

namespace LibraryTools;

/// <summary>
/// The Library Manager and the Publish wizard, exposed to the s&box editor's MCP server, so scripts and AI agents
/// can list, install, remove and publish sbox.game libraries the same way the editor's own buttons do.
/// </summary>
[McpToolset( "libraries", "Library Manager from MCP: list versions, install, uninstall and publish sbox.game libraries" )]
public static class LibraryTools
{
	// Installed libraries report idents like "rwtrcsc.lobbysystem#local"; compare the part before the #.
	static bool Matches( LibraryProject lib, string ident ) => (lib.Project?.Package?.FullIdent ?? "").Split( '#' )[0] == ident;

	/// <summary>Published versions of a library on sbox.game (newest first) and the version installed here, if any.</summary>
	/// <param name="ident">Full package ident, e.g. 'rwtrcsc.lobbysystem'.</param>
	[McpTool.ReadOnly( "library_versions" )]
	public static async Task<string> LibraryVersions( string ident )
	{
		var versions = await Package.FetchVersions( ident, default );
		var installed = LibrarySystem.All.FirstOrDefault( x => Matches( x, ident ) );
		var lines = (versions ?? Enumerable.Empty<Package.IRevision>())
			.Select( v => $"{v.VersionId}  {v.Created:yyyy-MM-dd HH:mm}  files={v.FileCount}  {v.Summary}" );
		return $"installed: {(installed is null ? "no" : installed.Version?.ToString() ?? "local")}\n" + string.Join( "\n", lines );
	}

	/// <summary>Install a published library version into Libraries/ (newest when versionId is 0).</summary>
	/// <param name="ident">Full package ident, e.g. 'rwtrcsc.lobbysystem'.</param>
	/// <param name="versionId">A version id from library_versions, or 0 for the newest.</param>
	[McpTool( "library_install" )]
	public static async Task<string> LibraryInstall( string ident, long versionId = 0 )
	{
		var versions = (await Package.FetchVersions( ident, default ))?.ToList();
		var v = versionId == 0 ? versions?.FirstOrDefault() : versions?.FirstOrDefault( x => x.VersionId == versionId );
		if ( v is null ) return $"No published version found for {ident}.";

		await LibrarySystem.Install( ident, v.VersionId, default );
		return $"Installed {ident} version {v.VersionId} (published {v.Created:yyyy-MM-dd}).";
	}

	/// <summary>Remove an installed library and delete its folder, as the Library Manager's uninstall button does.</summary>
	/// <param name="ident">Full package ident, e.g. 'rwtrcsc.lobbysystem'.</param>
	[McpTool( "library_uninstall" )]
	public static string LibraryUninstall( string ident )
	{
		var lib = LibrarySystem.All.FirstOrDefault( x => Matches( x, ident ) );
		if ( lib is null )
			return $"{ident} is not installed. Installed: " + string.Join( ", ", LibrarySystem.All.Select( x => x.Project?.Package?.FullIdent ) );
		lib.RemoveAndDelete();
		return $"Removed {ident}.";
	}

	/// <summary>
	/// Publish a library from Libraries/ as a new revision on sbox.game, running the Publish wizard's own steps:
	/// compile, gather files, upload, publish. Visibility is not set here: a new package starts Hidden (org members only)
	/// and you switch it to Released on its sbox.game Edit page.
	/// </summary>
	/// <param name="ident">Full package ident, e.g. 'rwtrcsc.combatsystem'.</param>
	/// <param name="changeTitle">Revision title shown on the package page.</param>
	[McpTool( "library_publish" )]
	public static async Task<string> LibraryPublish( string ident, string changeTitle = "" )
	{
		var project = LibrarySystem.All.FirstOrDefault( x => Matches( x, ident ) )?.Project;
		if ( project is null ) return $"{ident} is not in Libraries/.";
		if ( project.Config.Org == "local" ) return "Set an organisation in the .sbproj first.";

		var publisher = await ProjectPublisher.FromProject( project );

		// Same as the Publish wizard: compile only when there is game code (editor-only libraries ship as source).
		if ( project.HasCodePath() )
		{
			var output = await EditorUtility.Projects.Compile( project, _ => { } );
			if ( output is null || output.Length == 0 ) return "Compile produced no code.";

			await publisher.AddFile( System.Text.Json.JsonSerializer.Serialize( output.Select( x => x.Compiler.AssemblyName ).ToList() ), ".bin/manifest.json" );
			foreach ( var asm in output )
			{
				await publisher.AddFile( asm.XmlDocumentation, $".bin/{asm.Compiler.AssemblyName}.xml" );
				await publisher.AddFile( asm.Archive.Serialize(), $".bin/{asm.Compiler.AssemblyName}.cll" );
			}
		}
		await publisher.PrePublish();

		foreach ( var file in publisher.Files.Where( x => !x.Skip ).ToList() )
		{
			bool ok = file.Contents is not null
				? await project.Package.UploadFile( file.Contents, file.Name, _ => { }, default )
				: file.AbsolutePath is not null && await project.Package.UploadFile( file.AbsolutePath, file.Name, _ => { }, default );
			if ( ok ) file.Skip = true;
		}
		var failed = publisher.Files.Where( x => !x.Skip ).Select( x => x.Name ).ToList();
		if ( failed.Count > 0 ) return "Upload failed for: " + string.Join( ", ", failed );

		publisher.SetChangeDetails( string.IsNullOrWhiteSpace( changeTitle ) ? $"Changes on {System.DateTime.UtcNow:yyyy-MM-dd}" : changeTitle, "" );
		await publisher.Publish();
		return $"Published {ident} ({publisher.Files.Count()} files). Visibility is unchanged: new packages start Hidden; release them on sbox.game.";
	}
}