Skip to content

Commit a84a603

Browse files
committed
add MCP server
1 parent ea91b15 commit a84a603

File tree

7 files changed

+283
-4
lines changed

7 files changed

+283
-4
lines changed

TALXIS.CLI.sln

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
Microsoft Visual Studio Solution File, Format Version 12.00
23
# Visual Studio Version 17
34
VisualStudioVersion = 17.5.2.0
@@ -8,27 +9,62 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.Data", "src\TALX
89
EndProject
910
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI", "src\TALXIS.CLI\TALXIS.CLI.csproj", "{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}"
1011
EndProject
12+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TALXIS.CLI.MCP", "src\TALXIS.CLI.MCP\TALXIS.CLI.MCP.csproj", "{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}"
13+
EndProject
1114
Global
1215
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1316
Debug|Any CPU = Debug|Any CPU
17+
Debug|x64 = Debug|x64
18+
Debug|x86 = Debug|x86
1419
Release|Any CPU = Release|Any CPU
20+
Release|x64 = Release|x64
21+
Release|x86 = Release|x86
1522
EndGlobalSection
1623
GlobalSection(ProjectConfigurationPlatforms) = postSolution
1724
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
1825
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Debug|Any CPU.Build.0 = Debug|Any CPU
26+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Debug|x64.ActiveCfg = Debug|Any CPU
27+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Debug|x64.Build.0 = Debug|Any CPU
28+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Debug|x86.ActiveCfg = Debug|Any CPU
29+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Debug|x86.Build.0 = Debug|Any CPU
1930
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Release|Any CPU.ActiveCfg = Release|Any CPU
2031
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Release|Any CPU.Build.0 = Release|Any CPU
32+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Release|x64.ActiveCfg = Release|Any CPU
33+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Release|x64.Build.0 = Release|Any CPU
34+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Release|x86.ActiveCfg = Release|Any CPU
35+
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D}.Release|x86.Build.0 = Release|Any CPU
2136
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
2237
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
38+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Debug|x64.ActiveCfg = Debug|Any CPU
39+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Debug|x64.Build.0 = Debug|Any CPU
40+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Debug|x86.ActiveCfg = Debug|Any CPU
41+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Debug|x86.Build.0 = Debug|Any CPU
2342
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
2443
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Release|Any CPU.Build.0 = Release|Any CPU
44+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Release|x64.ActiveCfg = Release|Any CPU
45+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Release|x64.Build.0 = Release|Any CPU
46+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Release|x86.ActiveCfg = Release|Any CPU
47+
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F}.Release|x86.Build.0 = Release|Any CPU
48+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
49+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Debug|Any CPU.Build.0 = Debug|Any CPU
50+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Debug|x64.ActiveCfg = Debug|Any CPU
51+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Debug|x64.Build.0 = Debug|Any CPU
52+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Debug|x86.ActiveCfg = Debug|Any CPU
53+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Debug|x86.Build.0 = Debug|Any CPU
54+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Release|Any CPU.ActiveCfg = Release|Any CPU
55+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Release|Any CPU.Build.0 = Release|Any CPU
56+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Release|x64.ActiveCfg = Release|Any CPU
57+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Release|x64.Build.0 = Release|Any CPU
58+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Release|x86.ActiveCfg = Release|Any CPU
59+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B}.Release|x86.Build.0 = Release|Any CPU
2560
EndGlobalSection
2661
GlobalSection(SolutionProperties) = preSolution
2762
HideSolutionNode = FALSE
2863
EndGlobalSection
2964
GlobalSection(NestedProjects) = preSolution
3065
{5661B9D5-76FD-DAFA-278B-5B9BE78D957D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
3166
{047E218E-A6A2-1C66-58E1-AFEF0AD34E7F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
67+
{DFE5EC2E-21E2-42D6-B9C6-3111CE00FD0B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
3268
EndGlobalSection
3369
GlobalSection(ExtensibilityGlobals) = postSolution
3470
SolutionGuid = {53733BD6-A32A-41B7-9472-E377AF68151F}

src/TALXIS.CLI.MCP/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
bin/
2+
obj/

src/TALXIS.CLI.MCP/Program.cs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Hosting;
3+
using Microsoft.Extensions.Logging;
4+
using ModelContextProtocol;
5+
using ModelContextProtocol.Protocol;
6+
using ModelContextProtocol.Server;
7+
using System.Reflection;
8+
using System.Text.Json;
9+
using TALXIS.CLI;
10+
11+
var builder = new HostApplicationBuilder(args);
12+
builder.Logging.AddConsole(consoleLogOptions =>
13+
{
14+
// Configure all logs to go to stderr
15+
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
16+
});
17+
18+
builder.Services.AddMcpServer(options =>
19+
{
20+
var version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
21+
options.ServerInfo = new Implementation
22+
{
23+
Name = "TALXIS CLI MCP",
24+
Version = version
25+
};
26+
options.ServerInstructions = "This server is a wrapper for the TALXIS CLI. It allows MCP clients to execute the same commands through this server as if they were running the CLI in their terminal.";
27+
options.Capabilities = new ServerCapabilities
28+
{
29+
Tools = new ToolsCapability
30+
{
31+
ListChanged = true,
32+
ListToolsHandler = ListToolsAsync,
33+
CallToolHandler = CallToolAsync
34+
}
35+
};
36+
})
37+
.WithStdioServerTransport();
38+
39+
40+
41+
await builder.Build().RunAsync();
42+
43+
// Dynamically list all CLI commands and subcommands as MCP tools
44+
static ValueTask<ListToolsResult> ListToolsAsync(RequestContext<ListToolsRequestParams> ctx, CancellationToken ct)
45+
{
46+
var toolDefs = new List<Tool>();
47+
var rootType = typeof(TxcCliCommand);
48+
var rootCommands = new[] { rootType };
49+
foreach (var cmdType in rootCommands)
50+
{
51+
AddCommandAndChildren(cmdType, toolDefs, parentName: null, rootType: rootType);
52+
}
53+
return ValueTask.FromResult(new ListToolsResult { Tools = toolDefs });
54+
}
55+
56+
static void AddCommandAndChildren(Type cmdType, List<Tool> defs, string? parentName, Type rootType)
57+
{
58+
var attr = cmdType.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute;
59+
if (attr == null) return;
60+
var name = attr.Name ?? cmdType.Name.Replace("CliCommand", "").ToLowerInvariant();
61+
bool isRoot = cmdType == rootType;
62+
bool isDirectChildOfRoot = parentName == null && isRoot == false;
63+
bool isGroup = attr.Children != null && attr.Children.Length > 0;
64+
// If parent is root, don't include its name in the tool name
65+
var fullName = (parentName == null || parentName == (rootType.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute)?.Name || parentName == rootType.Name.Replace("CliCommand", "").ToLowerInvariant())
66+
? name : $"{parentName}-{name}";
67+
// Only register as a tool if it's not the root, not a direct child of root, and not a group
68+
if (!isGroup && !isRoot && !isDirectChildOfRoot)
69+
{
70+
var tool = new Tool
71+
{
72+
Name = fullName,
73+
Description = attr.Description,
74+
InputSchema = BuildInputSchema(cmdType)
75+
};
76+
defs.Add(tool);
77+
}
78+
if (attr.Children != null)
79+
{
80+
foreach (var child in attr.Children)
81+
AddCommandAndChildren(child, defs, fullName, rootType);
82+
}
83+
}
84+
85+
static JsonElement BuildInputSchema(Type cmdType)
86+
{
87+
var props = cmdType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
88+
.Select(p => (p, attr: p.GetCustomAttribute(typeof(DotMake.CommandLine.CliOptionAttribute)) as DotMake.CommandLine.CliOptionAttribute))
89+
.Where(x => x.attr != null)
90+
.ToList();
91+
var required = props.Where(x => x.attr != null && x.attr.Required)
92+
.Select(x => (x.attr!.Name ?? x.p.Name).TrimStart('-')).ToList();
93+
var properties = new Dictionary<string, object?>();
94+
foreach (var (p, attr) in props)
95+
{
96+
if (attr == null) continue;
97+
var type = p.PropertyType == typeof(bool) ? "boolean" : "string";
98+
var optionName = (attr.Name ?? p.Name).TrimStart('-');
99+
properties[optionName] = new Dictionary<string, object?>
100+
{
101+
["type"] = type,
102+
["description"] = attr.Description
103+
};
104+
}
105+
var schema = new Dictionary<string, object?>
106+
{
107+
["type"] = "object",
108+
["properties"] = properties,
109+
["required"] = required
110+
};
111+
return JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize(schema));
112+
}
113+
114+
// Call a CLI command by reconstructing args and running it
115+
static async ValueTask<CallToolResult> CallToolAsync(RequestContext<CallToolRequestParams> ctx, CancellationToken ct)
116+
{
117+
var p = ctx.Params;
118+
var toolName = p?.Name ?? string.Empty;
119+
if (string.IsNullOrEmpty(toolName))
120+
throw new McpException("Tool name is required.");
121+
// Always start traversal from TxcCliCommand
122+
Type? cmdType = FindCommandTypeByName(toolName, typeof(TxcCliCommand));
123+
if (cmdType == null)
124+
throw new McpException($"Tool '{toolName}' not found.");
125+
126+
// Build args from params, supporting subcommands (e.g. "data server")
127+
var cliArgs = toolName.Split('-', StringSplitOptions.RemoveEmptyEntries).ToList();
128+
if (p != null && p.Arguments is not null)
129+
{
130+
foreach (var entry in p.Arguments)
131+
{
132+
var k = entry.Key;
133+
var v = entry.Value;
134+
if (v.ValueKind != JsonValueKind.Null)
135+
cliArgs.Add($"--{k}={v}");
136+
}
137+
}
138+
139+
// Run the CLI command and capture output
140+
var output = new StringWriter();
141+
var origOut = Console.Out;
142+
Console.SetOut(output);
143+
try
144+
{
145+
// Use the CLI entry point
146+
await TALXIS.CLI.Program.RunCli(cliArgs.ToArray());
147+
}
148+
catch (Exception ex)
149+
{
150+
return new CallToolResult { Content = [new TextContentBlock { Text = ex.ToString(), Type = "text" }] };
151+
}
152+
finally
153+
{
154+
Console.SetOut(origOut);
155+
}
156+
return new CallToolResult { Content = [new TextContentBlock { Text = output.ToString(), Type = "text" }] };
157+
}
158+
159+
160+
static Type? FindCommandTypeByName(string toolName, Type root)
161+
{
162+
var segments = toolName.Split('-', StringSplitOptions.RemoveEmptyEntries);
163+
// Try matching as-is (without root segment)
164+
var found = FindCommandTypeBySegments(segments, 0, root);
165+
if (found != null)
166+
return found;
167+
// Try matching with root segment prepended
168+
var attr = root.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute;
169+
var rootName = attr?.Name ?? root.Name.Replace("CliCommand", "").ToLowerInvariant();
170+
var withRoot = new string[] { rootName }.Concat(segments).ToArray();
171+
return FindCommandTypeBySegments(withRoot, 0, root);
172+
}
173+
174+
static Type? FindCommandTypeBySegments(string[] segments, int index, Type type)
175+
{
176+
var attr = type.GetCustomAttribute(typeof(DotMake.CommandLine.CliCommandAttribute)) as DotMake.CommandLine.CliCommandAttribute;
177+
var cmdName = attr?.Name ?? type.Name.Replace("CliCommand", "").ToLowerInvariant();
178+
if (!string.Equals(cmdName, segments[index], StringComparison.OrdinalIgnoreCase))
179+
return null;
180+
if (index == segments.Length - 1)
181+
return type;
182+
if (attr?.Children != null)
183+
{
184+
foreach (var child in attr.Children)
185+
{
186+
var found = FindCommandTypeBySegments(segments, index + 1, child);
187+
if (found != null) return found;
188+
}
189+
}
190+
return null;
191+
}

src/TALXIS.CLI.MCP/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# TALXIS.CLI.MCP
2+
3+
This project hosts a ModelContextProtocol (MCP) server for the TALXIS CLI, advertising a dynamic list of CLI tools and allowing tool invocation via MCP stdio transport.
4+
5+
## Features
6+
- Dynamic discovery of CLI commands and subcommands using reflection
7+
- Implements MCP ListTools and CallTool handlers
8+
9+
## Usage
10+
11+
Build and run the server:
12+
13+
```sh
14+
dotnet run --project src/TALXIS.CLI.MCP
15+
```
16+
17+
The server will listen for MCP stdio requests and advertise available CLI tools.
18+
19+
20+
## Debugging Locally
21+
22+
You can debug the MCP server locally using the [Model Context Protocol Inspector](https://www.npmjs.com/package/@modelcontextprotocol/inspector):
23+
24+
```sh
25+
npx @modelcontextprotocol/inspector dotnet run --project src/TALXIS.CLI.MCP
26+
```
27+
28+
This will launch the MCP Inspector and connect it to the running server for interactive inspection and debugging.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net9.0</TargetFramework>
4+
<Nullable>enable</Nullable>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<OutputType>Exe</OutputType>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.6.25358.103" />
10+
<PackageReference Include="ModelContextProtocol" Version="0.3.0-preview.3" />
11+
<ProjectReference Include="../TALXIS.CLI/TALXIS.CLI.csproj" />
12+
<ProjectReference Include="../TALXIS.CLI.Data/TALXIS.CLI.Data.csproj" />
13+
</ItemGroup>
14+
</Project>

src/TALXIS.CLI/Program.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
using System.Threading.Tasks;
33
using DotMake.CommandLine;
44

5-
class Program
5+
namespace TALXIS.CLI
66
{
7-
public static async Task<int> Main(string[] args)
7+
public class Program
88
{
9-
return await Cli.RunAsync<TALXIS.CLI.TxcCliCommand>(args);
9+
public static async Task<int> Main(string[] args)
10+
{
11+
return await Cli.RunAsync<TALXIS.CLI.TxcCliCommand>(args);
12+
}
13+
14+
public static async Task<int> RunCli(string[] args)
15+
{
16+
return await Main(args);
17+
}
1018
}
1119
}

src/TALXIS.CLI/TxcCliCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace TALXIS.CLI;
44

55
[CliCommand(
6-
Description = "TALXIS CLI (txc): A modular .NET global tool for automating and managing Power Platform development workflows.",
6+
Description = "TALXIS CLI (txc): Tool for automating development loops in Power Platform.",
77
Children = new[] { typeof(TALXIS.CLI.Data.DataCliCommand) }
88
)]
99
public class TxcCliCommand

0 commit comments

Comments
 (0)