Skip to content

Commit ce69818

Browse files
authored
Merge pull request #458 from nblumhardt-ro/feature/mcp
Kick off `seqcli skills install` and `seqcli mcp run`
2 parents c29057e + 9cf0288 commit ce69818

29 files changed

Lines changed: 2240 additions & 19 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,7 @@ __pycache__/
292292
global.json
293293

294294
.DS_Store/
295+
296+
.claude/
297+
.qwen/
298+
.agents/
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright © Datalust and contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Threading.Tasks;
16+
using SeqCli.Mcp;
17+
using SeqCli.Util;
18+
19+
namespace SeqCli.Cli.Commands.Mcp;
20+
21+
[Command("mcp", "install", "Install or update the Seq MCP server for an agent",
22+
Example = "seqcli mcp install --global --agent claude")]
23+
class InstallCommand : Command
24+
{
25+
bool _global;
26+
string? _agent;
27+
string? _profile;
28+
29+
public InstallCommand()
30+
{
31+
Options.Add(
32+
"g|global",
33+
"Install for the current user globally; the default is to install into the current project directory",
34+
_ => _global = true);
35+
36+
Options.Add(
37+
"a=|agent=",
38+
"The agent name to install the MCP server for; the default is the generic name `agents`",
39+
t => _agent = ArgumentString.Normalize(t));
40+
41+
Options.Add(
42+
"profile=",
43+
"A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used",
44+
v => _profile = ArgumentString.Normalize(v));
45+
}
46+
47+
protected override Task<int> Run()
48+
{
49+
McpServerInstaller.Install(_agent?.ToLowerInvariant(), _global, _profile);
50+
return Task.FromResult(0);
51+
}
52+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright © Datalust and contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System;
16+
using System.Threading.Tasks;
17+
using Autofac.Extensions.DependencyInjection;
18+
using Microsoft.Extensions.DependencyInjection;
19+
using Microsoft.Extensions.Hosting;
20+
using SeqCli.Api;
21+
using SeqCli.Cli.Features;
22+
using SeqCli.Config;
23+
using SeqCli.Mcp;
24+
using SeqCli.Mcp.Tools.Search;
25+
using Serilog;
26+
27+
namespace SeqCli.Cli.Commands.Mcp;
28+
29+
[Command("mcp", "run", "Run an MCP (Model Context Protocol) server on STDIO")]
30+
class RunCommand: Command
31+
{
32+
readonly ConnectionFeature _connection;
33+
readonly StoragePathFeature _storagePath;
34+
bool _debug;
35+
36+
public RunCommand()
37+
{
38+
_connection = Enable<ConnectionFeature>();
39+
_storagePath = Enable<StoragePathFeature>();
40+
Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection",
41+
_ => _debug = true);
42+
}
43+
44+
protected override async Task<int> Run()
45+
{
46+
var config = RuntimeConfigurationLoader.Load(_storagePath);
47+
48+
if (_debug)
49+
{
50+
Log.Logger = new LoggerConfiguration()
51+
.Enrich.WithProperty("Application", "seqcli mcp run")
52+
.WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector()))
53+
.CreateLogger();
54+
55+
Log.Information("Seq MCP server starting up");
56+
}
57+
58+
try
59+
{
60+
var builder = Host.CreateApplicationBuilder();
61+
builder.ConfigureContainer(new AutofacServiceProviderFactory());
62+
builder.Services.AddSerilog();
63+
builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config));
64+
builder.Services.AddSingleton<McpSession>();
65+
builder.Services
66+
.AddMcpServer()
67+
.WithStdioServerTransport()
68+
.WithTools([
69+
typeof(SearchAndQueryToolType)
70+
]);
71+
72+
await builder.Build().RunAsync();
73+
}
74+
catch (Exception ex)
75+
{
76+
Log.Fatal(ex, "Unhandled exception");
77+
return 1;
78+
}
79+
finally
80+
{
81+
await Log.CloseAndFlushAsync();
82+
}
83+
return 0;
84+
}
85+
}

src/SeqCli/Cli/Commands/SearchCommand.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ protected override async Task<int> Run()
128128
}
129129
}
130130

131-
LogEvent ToSerilogEvent(EventEntity evt)
131+
internal static LogEvent ToSerilogEvent(EventEntity evt)
132132
{
133133
return new LogEvent(
134134
DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(),
@@ -149,12 +149,12 @@ static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart toke
149149
return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}");
150150
}
151151

152-
LogEventProperty CreateProperty(string name, object value)
152+
static LogEventProperty CreateProperty(string name, object value)
153153
{
154154
return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value));
155155
}
156156

157-
LogEventPropertyValue CreatePropertyValue(object value)
157+
static LogEventPropertyValue CreatePropertyValue(object value)
158158
{
159159
switch (value)
160160
{
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright © Datalust and contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Threading.Tasks;
16+
using SeqCli.Skills;
17+
using SeqCli.Util;
18+
19+
namespace SeqCli.Cli.Commands.Skills;
20+
21+
[Command("skills", "install", "Install or update Seq agent skills",
22+
Example = "seqcli skills install --global --agent claude")]
23+
class InstallCommand : Command
24+
{
25+
bool _global;
26+
string? _agent;
27+
28+
public InstallCommand()
29+
{
30+
Options.Add(
31+
"g|global",
32+
"Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills`",
33+
_ => _global = true);
34+
35+
Options.Add(
36+
"a=|agent=",
37+
"The agent name to install skills for; the default is the generic name `agents`",
38+
t => _agent = ArgumentString.Normalize(t));
39+
}
40+
41+
protected override Task<int> Run()
42+
{
43+
SkillInstaller.Install(_agent?.ToLowerInvariant(), _global);
44+
return Task.FromResult(0);
45+
}
46+
}

src/SeqCli/Ingestion/LogShipper.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ static async Task<BatchResult> ReadBatchAsync(
181181
if (isLast || batch.Count != 0 || totalWaitMS > maxWaitMS)
182182
break;
183183

184-
// Nothing to to ship; wait to try to fill a batch.
184+
// Nothing to ship; wait to try to fill a batch.
185185
await Task.Delay(idleWaitMS);
186186
totalWaitMS += idleWaitMS;
187187
continue;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright © Datalust and contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System;
16+
using System.Globalization;
17+
using System.IO;
18+
using System.Net.Http;
19+
using System.Text;
20+
using System.Threading;
21+
using System.Threading.Tasks;
22+
using Newtonsoft.Json;
23+
using Seq.Api;
24+
using Seq.Api.Model.Data;
25+
26+
namespace SeqCli.Mcp.Data;
27+
28+
public static class DataResourceGroupHelper
29+
{
30+
static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings
31+
{
32+
DateParseHandling = DateParseHandling.None,
33+
Culture = CultureInfo.InvariantCulture,
34+
FloatParseHandling = FloatParseHandling.Decimal,
35+
});
36+
37+
public static async Task<QueryResultPart> QueryPreserveErrorResponsesAsync(SeqConnection connection, string query, CancellationToken cancellationToken = default)
38+
{
39+
// Unfortunately, the `Data.QueryAsync()` API throws when the server 400s, making this case tricky. Suggests
40+
// we should make some API client improvements...
41+
var request = new HttpRequestMessage
42+
{
43+
RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative),
44+
Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json")
45+
};
46+
var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken);
47+
return Serializer.Deserialize<QueryResultPart>(
48+
new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!;
49+
}
50+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright © Datalust and contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System;
16+
using System.Collections.Generic;
17+
using System.Linq;
18+
using Seq.Api.Model.Data;
19+
20+
namespace SeqCli.Mcp.Data;
21+
22+
public static class QueryResultHelper
23+
{
24+
/// <summary>
25+
/// Convert <paramref name="result"/> into a flat table. Seq reduces browser-side processing and optimizes
26+
/// response sizes by constructing result trees for some grouped/time-sliced query results.
27+
/// </summary>
28+
public static void Flatten(QueryResultPart result, Action<IEnumerable<object?>> writeRow)
29+
{
30+
if (result.Error != null)
31+
return;
32+
33+
if (result.Rows != null)
34+
{
35+
writeRow(result.Columns!);
36+
foreach (var row in result.Rows)
37+
{
38+
writeRow(row);
39+
}
40+
}
41+
else if (result.Slices != null)
42+
{
43+
writeRow(new object[] {"time"}.Concat(result.Columns!));
44+
45+
var empty = result.Columns!.Select(_ => "").ToArray();
46+
foreach (var slice in result.Slices)
47+
{
48+
var any = false;
49+
foreach (var row in slice.Rows)
50+
{
51+
any = true;
52+
writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row));
53+
}
54+
if (!any)
55+
{
56+
writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty));
57+
}
58+
}
59+
}
60+
else if (result.Series != null)
61+
{
62+
writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault()));
63+
foreach (var series in result.Series)
64+
{
65+
foreach (var slice in series.Slices)
66+
{
67+
var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray();
68+
var any = false;
69+
foreach (var row in slice.Rows)
70+
{
71+
any = true;
72+
writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row));
73+
}
74+
if (!any)
75+
{
76+
writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty));
77+
}
78+
}
79+
}
80+
}
81+
else
82+
{
83+
throw new NotImplementedException("Query result set does not conform to any expected pattern.");
84+
}
85+
}
86+
87+
static IEnumerable<object> MergeColumns(string[] columns, TimeseriesPart? firstSeries)
88+
{
89+
if (firstSeries == null)
90+
yield break;
91+
92+
var i = 0;
93+
for (; i < firstSeries.Key.Length; ++i)
94+
{
95+
yield return columns[i];
96+
}
97+
98+
yield return "time";
99+
100+
for (; i < columns.Length; ++i)
101+
{
102+
yield return columns[i];
103+
}
104+
}
105+
}

0 commit comments

Comments
 (0)