C# quickstart

View as Markdown

In about 15 minutes you build a .NET console app that connects to an M-Files vault over native gRPC, logs in, lists the 10 most recently modified documents and logs out. The app uses the official Grpc.Net.Client and Google.Protobuf packages, client code generated from the M-Files protos, and a small connection helper from the samples that you can copy into your own project.

Prerequisites

Cloud and on-premises and Connecting to the server explain the endpoints in detail.

Get the samples and the protos

Download the gRPC API bundle and unpack it. It contains:

An M-Files Server installation also has the API definition, in <installation folder>\Common\Web\GRPC\proto, as one combined file without documentation. It describes the same wire contract, but its generated C# namespace is MFiles rather than MFiles.Grpc. Prefer the bundle.

Set up the project

The samples are a solution with three projects:

Project What it is
Connection Class library: the generated client code and the MFilesConnection helper
Quickstart The console app of this guide
Recipes One console program per task recipe

Connection references three packages. Grpc.Tools is needed only at build time:

<ItemGroup>
  <PackageReference Include="Google.Protobuf" Version="3.36.2" />
  <PackageReference Include="Grpc.Net.Client" Version="2.85.0" />
  <PackageReference Include="Grpc.Tools" Version="2.85.0" PrivateAssets="All" />
</ItemGroup>

Source: csharp/Connection/Connection.csproj

Grpc.Tools generates the client code from the protos on every build. ProtoRoot must be the folder that contains mfiles/, because the files import each other by that path:

<!-- Generate client classes for every service of the bundle. ProtoRoot must be the folder that
     contains mfiles/, because the protos import each other as "mfiles/structs.proto". -->
<ItemGroup>
  <Protobuf Include="$(MFilesProtoDir)/mfiles/**/*.proto" ProtoRoot="$(MFilesProtoDir)"
            GrpcServices="Client" Access="Public" />
</ItemGroup>

Source: csharp/Connection/Connection.csproj

MFilesProtoDir is set in Directory.Build.props: the MFILES_PROTO_DIR environment variable if it is set, otherwise ../../proto, which is where the bundle keeps the protos. To use protos elsewhere, set MFILES_PROTO_DIR to the folder that contains mfiles/structs.proto.

The generated classes are in the namespace MFiles.Grpc: a class per message, such as SearchObjectsRequest, and a client per service, such as IRPCSearch.IRPCSearchClient.

To start a project of your own instead, run dotnet new console, add the three packages, and copy the Protobuf item and the MFiles*.cs files of Connection.

Configure

The samples read their settings from environment variables:

Variable Value
MFILES_ADDRESS host:port, for example contoso.cloudvault.m-files.com:443 or mfserver:7766
MFILES_TLS true (default), or false for a server without TLS
MFILES_CA_CERT Optional: a PEM file of the root certificate to trust, for a server whose certificate comes from a private CA
MFILES_VAULT The vault GUID, with braces
MFILES_USERNAME, MFILES_PASSWORD An M-Files user account

M-Files Cloud:

$env:MFILES_ADDRESS = 'contoso.cloudvault.m-files.com:443'
$env:MFILES_VAULT = '{7BBEA0BC-40D8-4BCB-A67D-8A3ACFBDB575}'
$env:MFILES_USERNAME = 'alice'
$env:MFILES_PASSWORD = Read-Host -Prompt 'Password'

On-premises without TLS:

$env:MFILES_ADDRESS = 'mfserver:7766'
$env:MFILES_TLS = 'false'
$env:MFILES_VAULT = '{7BBEA0BC-40D8-4BCB-A67D-8A3ACFBDB575}'
$env:MFILES_USERNAME = 'alice'
$env:MFILES_PASSWORD = Read-Host -Prompt 'Password'

Single sign-on. A vault that signs users in through M-Files Login Service, as is typical on M-Files Cloud, refuses a password login with "Authentication failed.". Set MFILES_TOKEN to an access token from the Login Service, and MFILES_AUTH_PLUGIN and MFILES_AUTH_SCOPE to the plugin and scope that the vault returns. M-Files Login Service shows how to get the token; the Python sample recipes/login_service.py does it end to end.

Run

cd samples\csharp
dotnet run --project Quickstart

The first build restores the packages and generates the client code, which takes a minute. The output lists the documents, newest first. In the M-Files Sample Vault it starts like this:

Project Plan (template) (ID 445, version 1, modified 2012-06-07 12:01:57 UTC)
Project Tasks - CRM Development (ID 444, version 1, modified 2012-06-07 11:54:29 UTC)
Corporate Presentation (ID 443, version 1, modified 2012-06-07 11:40:14 UTC)
...

The code

using MFiles.Grpc;
using MFiles.Grpc.Samples;
using static MFiles.Grpc.Samples.MFilesValues;

// Connect and log in with the MFILES_* settings. Disposing the connection logs out.
await using var connection = await MFilesConnection.ConnectAsync(
    MFilesSettings.FromEnvironment(), clientName: "M-Files gRPC quickstart");

var request = new SearchObjectsRequest
{
    // Documents (object type 0) that are not deleted.
    Conditions = { new SearchConditionArray { Value = { IsObjectType(0), IsNotDeleted() } } },
    // Newest first by property 21, "Last modified". The server sorts, then takes 10.
    Sorting = new SearchResultsSorting
    {
        Rules = new SortRuleValueArray
        {
            Values = { new SortRule { Key = 21, Options = new SortRuleFlags { SortDescending = true } } },
        },
    },
    Limit = 10,
};

// Service clients run on the connection's call invoker, which adds the session to each call.
var search = new IRPCSearch.IRPCSearchClient(connection.CallInvoker);
var response = await search.SearchObjectsAsync(request);

foreach (var item in response.Results)
{
    var version = item.Object.VersionInfo;
    Console.WriteLine($"{version.Title} (ID {item.Object.ObjectInfo.ObjId.ItemId.InternalId}, " +
        $"version {version.Version.InternalVersion}, " +
        $"modified {version.LastModifiedAtUtc.ToDateTime():yyyy-MM-dd HH:mm:ss} UTC)");
}

Source: csharp/Quickstart/Program.cs

IsObjectType and IsNotDeleted are small builders from the helper. They show the message shapes of the two conditions:

/// <summary>A condition on a property value, e.g. property 100 (Class) equals a lookup.</summary>
public static SearchCondition PropertyCondition(
    int propertyDef, ConditionType type, TypedValue value) => new()
{
    Expression = new Expression
    {
        Type = ExpressionType.PropertyValue,
        Data = new ExpressionUnion
        {
            PropertyValue = new ExpressionUnion.Types.PropertyValueData
            {
                PropertyDef = propertyDef,
            },
        },
    },
    Type = type,
    Value = value,
};

/// <summary>A condition on a status value, e.g. the object type or the deleted flag.</summary>
public static SearchCondition StatusCondition(
    StatusType status, ConditionType type, TypedValue value) => new()
{
    Expression = new Expression
    {
        Type = ExpressionType.StatusValue,
        Data = new ExpressionUnion
        {
            StatusValue = new ExpressionUnion.Types.StatusValueData { Type = status },
        },
    },
    Type = type,
    Value = value,
};

/// <summary>Objects of one object type. Value list -11 lists the object types.</summary>
public static SearchCondition IsObjectType(int objectType) =>
    StatusCondition(StatusType.ObjectType, ConditionType.Equal, Lookup(-11, objectType));

/// <summary>Objects that are not deleted.</summary>
public static SearchCondition IsNotDeleted() =>
    StatusCondition(StatusType.IsDeleted, ConditionType.Equal, Boolean(false));

Source: csharp/Connection/MFilesValues.cs

What the helper does

gRPC alone is not enough to talk to M-Files: part of the contract travels in call metadata, and errors arrive in trailers. MFilesConnection takes care of that.

The channel. TLS or plain HTTP/2, an optional private root certificate, and the message size limits that M-Files' own client uses: responses of any size, requests up to 48 MiB. With an http:// address, .NET makes an unencrypted HTTP/2 connection; no extra switch is needed on .NET 10. SocketsHttpHandler keeps cookies, so the affinity cookie that M-Files Cloud sets (mfilesmsm) goes back on later calls, as Call metadata requires:

var handler = new SocketsHttpHandler
{
    // M-Files Cloud answers with a server affinity cookie (mfilesmsm); the handler's
    // cookie container sends it back on later calls.
    UseCookies = true,
    // gRPC multiplexes calls on one HTTP/2 connection; allow more when it is busy.
    EnableMultipleHttp2Connections = true,
    KeepAlivePingDelay = TimeSpan.FromSeconds(60),
    KeepAlivePingTimeout = TimeSpan.FromSeconds(30),
};
if (settings.CaCertificatePath is { } caPath)
{
    // Trust this root (e.g. an on-premises server's own CA) instead of the system's roots.
    var roots = new X509Certificate2Collection();
    roots.ImportFromPemFile(caPath);
    handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy
    {
        TrustMode = X509ChainTrustMode.CustomRootTrust,
        // A private CA usually publishes no revocation list.
        RevocationMode = X509RevocationMode.NoCheck,
    };
    handler.SslOptions.CertificateChainPolicy.CustomTrustStore.AddRange(roots);
}

// "http://" makes an unencrypted HTTP/2 connection (lab servers only).
string scheme = settings.UseTls ? "https" : "http";
var channel = GrpcChannel.ForAddress($"{scheme}://{settings.Address}", new GrpcChannelOptions
{
    HttpHandler = handler,
    // No limit (null) on received messages: responses can be much larger than gRPC's
    // default of 4 MiB.
    MaxReceiveMessageSize = null,
    MaxSendMessageSize = MaxSendMessageSize,
});

Source: csharp/Connection/MFilesConnection.cs

Call metadata. A call invoker adds the M-Files metadata to every call, and after login the session ID:

/// <summary>
/// The metadata M-Files expects on every call. gRPC base64-encodes "-bin" values itself.
/// </summary>
private CallOptions WithMFilesMetadata(CallOptions options)
{
    var headers = new Metadata();
    foreach (var entry in options.Headers ?? [])
        headers.Add(entry);

    // The caller is not one of the server's own local processes.
    headers.Add("mfiles-is-remote-call", "true");

    // A new 16-byte ID per call; it identifies the call in the server's logs.
    headers.Add("mfiles-activity-id-bin", Guid.NewGuid().ToByteArray());

    // After LogIn: the session ID, unchanged.
    if (connection.SessionId is { } sessionId)
        headers.Add("mfiles-session-id-bin", sessionId.ToByteArray());

    return options.WithHeaders(headers);
}

Source: csharp/Connection/MFilesCallInvoker.cs

Login. LogIn with M-Files credentials returns a session; its session_id goes into mfiles-session-id-bin from then on:

var authentication = new AuthDataClient
{
    Type = AuthDataType.Credentials,
    Data = new AuthDataClientUnion
    {
        Credentials = new Credentials
        {
            Username = username,
            Password = password,
            Type = CredentialsType.Mfiles,  // M-Files authentication
        },
    },
};

Source: csharp/Connection/MFilesConnection.cs

var request = new LogInRequest
{
    EnvironmentData = new EnvironmentData { ClientName = clientName, HostPlatform = ".NET" },
    ClientData = new ClientData
    {
        Type = ClientType.ServerApi,
        Language = "en",
        // Needed for the 32-byte attempt ID of a token login.
        Capabilities = new ClientCapabilities { LongAuthenticationAttemptIdentifier = true },
    },
    LoginData = new LoginData { ServerHostname = _host, VaultGuid = vault },
    AuthenticationData = authentication,
};
var login = new IRPCLogin.IRPCLoginClient(CallInvoker);
var response = await login.LogInAsync(request, cancellationToken: cancellationToken);

if (response.SessionData is not { SessionId.IsEmpty: false } session)
    throw new InvalidOperationException(
        "LogIn returned no session; this helper supports one-step logins only.");

// Every later call sends this session ID in mfiles-session-id-bin (see MFilesCallInvoker).
Session = session;

Source: csharp/Connection/MFilesConnection.cs

Errors. A failed call throws MFilesException, decoded from the mfiles-error-serialized-bin and mfiles-error-text-bin trailers: the gRPC status (StatusCode), the message, the M-Files error code of the top-level error (ErrorCode, an HRESULT such as 0x8004000B "Not found."; the last entry of the error stack that is not the generic 0x80040001), every code of the stack (ErrorCodes) and Has(code):

/// <summary>The generic code of an error that is only passed up the stack.</summary>
private const int PassThrough = unchecked((int)0x80040001);

/// <summary>
/// Decodes the M-Files error from the trailers of a failed call.
/// </summary>
public static MFilesException FromRpcException(RpcException e)
{
    // The error stack: a serialized MFiles.RPCErrorInfoArray, the original error first.
    var errors = e.Trailers.GetValueBytes("mfiles-error-serialized-bin") is { } stack
        ? RPCErrorInfoArray.Parser.ParseFrom(stack).Value.ToList()
        : [];

    // The long error message: UTF-8 text in a binary header.
    string? details = e.Trailers.GetValueBytes("mfiles-error-text-bin") is { } text
        ? System.Text.Encoding.UTF8.GetString(text)
        : null;

    // The call failed with the top-level error, the last entry; the status detail is its
    // message. Skip entries that only pass the error on.
    int? errorCode = errors.Count > 0 ? errors[^1].ErrorCode : null;
    if (errors.LastOrDefault(error => error.ErrorCode != PassThrough) is { } topLevel)
        errorCode = topLevel.ErrorCode;
    return new MFilesException(e, errorCode, details, errors);
}

Source: csharp/Connection/MFilesException.cs

Catch it like this:

try
{
    await objects.GetObjectInfoAndPropertiesAsync(request);
}
catch (MFilesException e) when (e.Has(0x8004000B))  // not found
{
    Console.WriteLine($"{e.ErrorCodeHex}: {e.Message}");
}

Errors lists the common codes and what to do about each.

Troubleshooting

dotnet run --project Recipes -- connect calls QueryVersion before it logs in, so it tells connection problems from login problems.

Symptom Cause
Build error "M-Files protos not found" Set MFILES_PROTO_DIR to the folder that contains mfiles/structs.proto.
Status Unavailable, "Error connecting to subchannel" Wrong host or port, or a firewall in between.
Status Internal, "The SSL connection could not be established" ... UntrustedRoot The server's certificate comes from a CA your machine does not trust. Set MFILES_CA_CERT to its root certificate (PEM).
Status Internal, "The SSL connection could not be established" ... "corrupted frame" TLS against a server without TLS: set MFILES_TLS=false, only for a lab server on a trusted network.
Status Unimplemented, "HTTP status code: 404", against M-Files Cloud MFILES_TLS=false; M-Files Cloud needs TLS.
M-Files error 0x8004001A, "Authentication failed." Wrong user name or password, or the vault uses single sign-on (see Configure).

Next steps