Skip to content
Open
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
2 changes: 1 addition & 1 deletion mcp/AnythinkMcp.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand Down
40 changes: 35 additions & 5 deletions mcp/McpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,44 @@
namespace AnythinkMcp;

/// <summary>
/// Resolves a profile name (or the active default) into an authenticated
/// <see cref="AnythinkClient"/>. Uses the same config files and token-refresh
/// logic as the CLI — credentials stored by <c>anythink login</c> work here too.
/// Resolves credentials into an authenticated <see cref="AnythinkClient"/>.
///
/// In stdio mode: uses CLI config files and saved profiles (same as the CLI).
/// In HTTP mode: uses per-request credentials passed via <see cref="SetRequestCredentials"/>.
/// </summary>
public class McpClientFactory
{
private readonly string? _profileName;
private readonly HttpMessageHandler? _httpHandler;

// Per-request credentials for HTTP mode — AsyncLocal flows correctly across async/await
private static readonly AsyncLocal<(string OrgId, string BaseUrl, string Token)?> _requestCredentials = new();

public string? ProfileName => _profileName;

public McpClientFactory(string? profileName = null)
{
_profileName = profileName;
}

/// <summary>
/// Sets per-request credentials for HTTP mode. Must be called before tool execution.
/// Thread-static so concurrent requests don't interfere.
/// </summary>
public static void SetRequestCredentials(string orgId, string baseUrl, string token)
{
_requestCredentials.Value = (orgId, baseUrl, token);
}

/// <summary>Clears per-request credentials after the request completes.</summary>
public static void ClearRequestCredentials()
{
_requestCredentials.Value = null;
}

/// <summary>Returns true if running in HTTP mode with per-request credentials.</summary>
public static bool IsHttpMode => _requestCredentials.Value.HasValue;

/// <summary>Test-only constructor — injects a mock HTTP handler for all clients.</summary>
internal McpClientFactory(string? profileName, HttpMessageHandler httpHandler)
{
Expand Down Expand Up @@ -49,11 +71,19 @@ public BillingClient GetUnauthenticatedBillingClient()
}

/// <summary>
/// Returns an authenticated client for the configured profile.
/// Refreshes expired JWT tokens automatically (same logic as the CLI).
/// Returns an authenticated client. In HTTP mode, uses per-request credentials.
/// In stdio mode, uses CLI config files and refreshes expired tokens.
/// </summary>
public AnythinkClient GetClient()
{
// HTTP mode: use per-request credentials (no config files)
if (_requestCredentials.Value.HasValue)
{
var creds = _requestCredentials.Value.Value;
return new AnythinkClient(creds.OrgId, creds.BaseUrl, creds.Token);
}

// Stdio mode: resolve from CLI config
var profile = !string.IsNullOrEmpty(_profileName)
? ConfigService.GetProfile(_profileName)
?? throw new InvalidOperationException(
Expand Down
173 changes: 173 additions & 0 deletions mcp/McpToolRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System.Reflection;
using System.Text.Json;
using AnythinkMcp.Tools;
using ModelContextProtocol.Server;

namespace AnythinkMcp;

/// <summary>
/// Registry for MCP tools — discovers tools from the assembly and provides
/// execution by name for the HTTP transport. In stdio mode, the MCP SDK
/// handles this automatically; in HTTP mode we need to invoke tools manually.
/// </summary>
public static class McpToolRegistry
{
private static readonly Dictionary<string, ToolInfo> Tools = DiscoverTools();

/// <summary>Returns tool definitions in Claude API tool_use format.</summary>
public static List<object> GetToolDefinitions()
{
return Tools.Values.Select(t => new
{
name = t.Name,
description = t.Description,
input_schema = t.InputSchema
}).Cast<object>().ToList();
}

/// <summary>Executes a tool by name, returning the text result.</summary>
public static async Task<string> ExecuteToolAsync(string toolName, JsonElement arguments,
IServiceProvider services)
{
if (!Tools.TryGetValue(toolName, out var tool))
throw new ArgumentException($"Unknown tool: {toolName}");

var factory = services.GetRequiredService<McpClientFactory>();

// Create an instance of the tool class (all tool classes take McpClientFactory in constructor)
var instance = Activator.CreateInstance(tool.DeclaringType, factory)!;

// Build method arguments from the JSON
var methodParams = tool.Method.GetParameters();
var invokeArgs = new object?[methodParams.Length];

for (var i = 0; i < methodParams.Length; i++)
{
var param = methodParams[i];
if (arguments.TryGetProperty(ToCamelCase(param.Name!), out var value) ||
arguments.TryGetProperty(param.Name!, out value))
{
invokeArgs[i] = ConvertJsonElement(value, param.ParameterType);
}
else if (param.HasDefaultValue)
{
invokeArgs[i] = param.DefaultValue;
}
else
{
invokeArgs[i] = param.ParameterType.IsValueType
? Activator.CreateInstance(param.ParameterType)
: null;
}
}

// Invoke and await
var result = tool.Method.Invoke(instance, invokeArgs);
if (result is Task<string> taskString)
return await taskString;
if (result is Task task)
{
await task;
return "OK";
}
return result?.ToString() ?? "";
}

private static Dictionary<string, ToolInfo> DiscoverTools()
{
var tools = new Dictionary<string, ToolInfo>();

// Find all types with [McpServerToolType] attribute
var toolTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => t.GetCustomAttribute<McpServerToolTypeAttribute>() != null);

foreach (var type in toolTypes)
{
// Find methods with [McpServerTool] attribute
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
var toolAttr = method.GetCustomAttribute<McpServerToolAttribute>();
if (toolAttr == null) continue;

var descAttr = method.GetCustomAttribute<System.ComponentModel.DescriptionAttribute>();

var name = toolAttr.Name ?? method.Name;
var description = descAttr?.Description ?? "";

// Build input schema from method parameters
var properties = new Dictionary<string, object>();
var required = new List<string>();

foreach (var param in method.GetParameters())
{
var paramDesc = param.GetCustomAttribute<System.ComponentModel.DescriptionAttribute>();
var paramName = ToCamelCase(param.Name!);

properties[paramName] = new
{
type = GetJsonType(param.ParameterType),
description = paramDesc?.Description ?? param.Name
};

if (!param.HasDefaultValue && !IsNullable(param.ParameterType))
required.Add(paramName);
}

tools[name] = new ToolInfo
{
Name = name,
Description = description,
DeclaringType = type,
Method = method,
InputSchema = new
{
type = "object",
properties,
required = required.ToArray()
}
};
}
}

return tools;
}

private static string GetJsonType(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
if (type == typeof(string)) return "string";
if (type == typeof(int) || type == typeof(long) || type == typeof(double) || type == typeof(float)) return "number";
if (type == typeof(bool)) return "boolean";
return "string";
}

private static bool IsNullable(Type type) =>
!type.IsValueType || Nullable.GetUnderlyingType(type) != null;

private static string ToCamelCase(string name) =>
string.IsNullOrEmpty(name) ? name : char.ToLowerInvariant(name[0]) + name[1..];

private static object? ConvertJsonElement(JsonElement element, Type targetType)
{
targetType = Nullable.GetUnderlyingType(targetType) ?? targetType;

if (element.ValueKind == JsonValueKind.Null) return null;
if (targetType == typeof(string)) return element.GetString();
if (targetType == typeof(int)) return element.GetInt32();
if (targetType == typeof(long)) return element.GetInt64();
if (targetType == typeof(bool)) return element.GetBoolean();
if (targetType == typeof(double)) return element.GetDouble();
if (targetType == typeof(Guid)) return Guid.Parse(element.GetString()!);

return element.GetString();
}
}

internal class ToolInfo
{
public string Name { get; init; } = "";
public string Description { get; init; } = "";
public Type DeclaringType { get; init; } = null!;
public MethodInfo Method { get; init; } = null!;
public object InputSchema { get; init; } = null!;
}
Loading