Lykos.Bifrost.Core 0.13.0

Prefix Reserved
dotnet add package Lykos.Bifrost.Core --version 0.13.0
                    
NuGet\Install-Package Lykos.Bifrost.Core -Version 0.13.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Lykos.Bifrost.Core" Version="0.13.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Lykos.Bifrost.Core" Version="0.13.0" />
                    
Directory.Packages.props
<PackageReference Include="Lykos.Bifrost.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Lykos.Bifrost.Core --version 0.13.0
                    
#r "nuget: Lykos.Bifrost.Core, 0.13.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Lykos.Bifrost.Core@0.13.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Lykos.Bifrost.Core&version=0.13.0
                    
Install as a Cake Addin
#tool nuget:?package=Lykos.Bifrost.Core&version=0.13.0
                    
Install as a Cake Tool

Bifrost

An MCP server and CLI for Claude Code and any other MCP client: language servers, code search, and tagged file editing, all rooted per call instead of per session.

Why

Claude Code's builtin LSP support binds one workspace root at spawn time — the session's working directory. Files outside it resolve to nothing, and the failure is quiet: documentSymbol on a file in another repo returns "No symbols found", which reads exactly like a correct empty answer. Granting directory access afterwards does not help, because the already-running server is never re-initialised.

Bifrost keeps a pool of language servers keyed by workspace root. The root is derived from the file path on every call, so a session opened anywhere can ask about any repo on disk, and several repos can be live at once.

lsp(operation: "references", filePath: "F:/Repos/Lykos-Chat/Lykos.Chat.Core/Services/EmojiStreamConverter.cs", line: 33, character: 19)
  -> [csharp @ F:\Repos\Lykos-Chat\Lykos.Chat.Core]

Quick start (Claude Code)

dotnet tool install -g Lykos.Bifrost
claude mcp add-json bifrost '{ "command": "bifrost", "args": ["mcp"] }' --scope user

Then, so the edit tools do not prompt on every call in non-bypass modes, allow them in ~/.claude/settings.json:

{ "permissions": { "allow": ["mcp__bifrost__read", "mcp__bifrost__edit", "mcp__bifrost__write"] } }

Optional, and worth it: the hooks/route_edits.py PreToolUse hook steers the session toward the tagged read and the batched edit. In --soft mode it only adds a note when a builtin Read targets a text file or a Bash command edits an existing file in place (sed -i, a heredoc into a tracked file); without --soft it denies, and # shell-edit in the command is the explicit override. Register it with its path:

{ "hooks": { "PreToolUse": [
  { "matcher": "Read",
    "hooks": [{ "type": "command", "command": "python", "args": ["<repo>/hooks/route_edits.py", "--soft"] }] },
  { "matcher": "Bash|PowerShell",
    "hooks": [{ "type": "command", "command": "python", "args": ["<repo>/hooks/route_edits.py", "--soft"] }] }
] } }

Language servers are installed separately and only started when a matching file is touched; see Language server prerequisites. The Claude desktop app renders read and edit results as a file view and a coloured diff through the lykos-claude-emoji loader; everywhere else they are the plain text below.

Tools

lsp

arg notes
operation hover, definition, references, implementation, documentSymbol, workspaceSymbol, incomingCalls, outgoingCalls, diagnostics
filePath absolute path; the workspace root is detected from it
line 1-based, as shown in editors (LSP itself is 0-based; Bifrost converts)
symbol the identifier on that line — "Push", or "Push#2" for the 2nd occurrence
character 1-based column; only needed when symbol cannot identify the position
query workspaceSymbol only
newOnly diagnostics only — report just what changed since the last check
projectDir pin the root when auto-detection picks wrong (nested solutions)

Address by name, not by column

Name the symbol and the column is resolved for you:

lsp(operation: "references", filePath: ".../EmojiStreamConverter.cs", line: 33, symbol: "Push")

Matching is word-boundary aware (Push will not match inside PushAll), exact case wins over a differently-cased earlier match, and a miss is an error rather than a silent fall back to column 0 — a position that lands on the wrong token yields a confident answer about the wrong symbol, which is worse than no answer. For that reason definition, references, implementation and the call hierarchy operations require symbol (or an explicit character) rather than accepting a bare line.

Output is grouped, not repeated

Results fold their shared directory prefix into one header instead of restating it per hit:

9 results
# Services/EmojiStreamConverter.cs
      18:36
      37:13
      44:43

A chain of single directories folds into one header, a directory holding exactly one file inlines it (the way an editor's tree compacts a lone child), and files with nothing to say about them share a line, collapsing shared affixes into brace syntax when that is shorter:

# src/lsp/
  client.ts config.ts render.ts
# test/tools/lsp-{batching,diagnostics-dedup,regressions}.test.ts

Diagnostics: what changed

newOnly: true reports only problems not seen on the previous check of that file. Identity ignores the line number, so an edit that shifts a pre-existing warning does not resurface it as new. Suppressed counts are always stated — a silently shortened list would read as "the problems are gone".

grep and glob

Deliberately shaped as stand-ins for the builtin Grep and Glob tools — same parameter names, same output_mode values, patterns are regexes by default — so they can be called with the same habits. What they add is reach (any directory on disk), .gitignore awareness, a named type filter, and grouped output.

arg notes
pattern a regex, as with builtin Grep; set fixed: true to match literally
path absolute directory (or a single file)
glob *.cs matches that name at any depth; src/**/*.cs is path-scoped
fixed, caseSensitive, context, word, invert, maxPerFile caseSensitive omitted means smart case
output_mode content, files_with_matches, or count (short forms files/counts also accepted; an unknown value errors)
type named filter, e.g. cs, ts, py, config; combines with glob
headLimit, offset paging; the elided count is always printed, never dropped silently
11 matches in 5 files  [.../coding-agent/src]
# lsp/utils.ts
      7: import { formatGroupedFiles } from "../tools/grouped-file-output";
      206: const grouped = formatGroupedFiles(fileOrder, filePath => ({
# tools/
  grep.ts
      41: import { classifyGroupedLines, formatGroupedFiles } from "./grouped-file-output";

Powered by Glacier.Grep for the part worth not writing by hand — a hierarchical .gitignore/.ignore/.rgignore walker. Two of its edges are wrapped rather than exposed: its globs match the file name only, so path-scoped globs are filtered here instead of passed down (handing one through matches nothing at all — an empty result that reads like "no matches"), and its result list is uncapped, so maxResults is enforced on our side. Its regex engine is non-backtracking, so lookaround and backreferences raise a clear error rather than failing obscurely.

read, edit, write

The write side. read returns numbered lines under a four-hex tag of the file's text; edit applies a batch of patches across files in one call and refuses loudly when the file moved or a count is wrong; write creates a file, or overwrites one only with the tag from its last read. The design, the measurements behind it, and every format detail live in docs/design/edit-and-run.md.

read(path: "src/Foo.cs", offset: 12, limit: 4)
  -> [src/Foo.cs#A1B2] lines 12-15 of 40
     12:    public int Bar(int x)
     13:    {
     14:        return x;
     15:    }

A patch document names each file once and then says what changes. Line hunks address the numbers from the tagged read and carry only the new content: a 40-line rewrite costs 40 lines, not 80. Text patches need no read and carry an expected match count instead, so a sweep that finds eight of ten places reports rather than half-applies. A CUT captures what it deletes into a register, so moving a block between files costs its lines once rather than twice.

[src/Foo.cs#A1B2]
PUT 12.=15:
+    public int Bar(int x) => Baz(x);
CUT 20.=22 @helper

[src/Bar.cs#0C3D]
PUT >$ @helper
MV src/Helpers.cs

[Justfile]
FIND "old-target" EXPECT 2
+new-target

Every body row starts with +, which is stripped; the whitespace after it is kept. There are no context rows and no -old rows: the range says what goes, the body says what arrives. The verbs are PUT, CUT, REM, MV, FIND and REGEX, and the grammar is written out once, in the edit tool's own description — run bifrost mcp and ask your client for the tool list, or read it in src/Bifrost/Mcp/EditTools.cs. The reasoning behind it is in docs/design/edit-and-run.md.

Pick the anchor by what you know: line hunks after a read or for a big rewrite, FIND for text you remember, REGEX for a shape in many places, a register for lines that are moving rather than changing. A line hunk without a tag is refused; that is sed -i '12s/…/', whose failure mode is applying cleanly to the wrong line. REM and MV need the tag too, and are the last thing the call does to that path.

The result is written for the model that has to make the next edit: each changed region under the file's new tag, with post-edit line numbers and the shift after it, so the next hunk re-grounds without a fresh read. A line hunk's removed rows are left out (the caller read them one call ago); a FIND or REGEX patch shows what it replaced. mode: independent applies what works and names each failure; the default is atomic, and a failed batch returns a single-use retry token so the next call sends only the corrections. dry_run validates and reports without writing.

[src/Foo.cs#4754 was #A1B2]  2 hunks  +2 -4
lines 12-12 (was 12-15, shift -3)
  11  
+ 12      public int Bar(int x) => Baz(x);
  13  

Mixing with the builtin Edit on the same file is safe. structuredContent (the full hunks plus a unified diff per file) is attached only when the server runs with --structured or BIFROST_STRUCTURED=1, for a host that renders from it: Claude Code hands the model that JSON in place of the text when it is present.

lsp_servers

Lists running servers and the root each is bound to. restart: true stops them (optionally filtered by root) so the next call starts fresh — use after changing a toolchain or installing a dependency.

Root detection

Walks up from the file looking for the server's rootMarkers, bounded by the enclosing git repo so a stray marker in an unrelated parent can never pull the root outside it.

language markers strategy
C# *.sln, *.slnx, *.csproj outermost — the solution at the repo root, so cross-project references resolve
TypeScript tsconfig.json, jsconfig.json, package.json nearest — each package in a monorepo owns its root
Python pyrightconfig.json, pyproject.toml, setup.py, setup.cfg, requirements.txt nearest
Rust Cargo.toml outermost — the workspace root, so cross-crate references resolve

Falls back to the git root, then the file's own directory.

Check what a file resolves to without starting anything:

bifrost resolve F:/Repos/Lykos-Chat/Lykos.Chat.Core/Services/EmojiStreamConverter.cs

CLI

The same binary is a command line tool, and every command routes through the classes the MCP tools use — so trying something in the shell exercises the server, and output cannot be right in one and wrong in the other.

bifrost grep formatGroupedFiles -p ./src -g "*.ts" -l
bifrost glob --type cs -p ./src
bifrost lsp hover ./src/Services/EmojiStreamConverter.cs -l 33 -s Push
bifrost lsp references ./src/Services/EmojiStreamConverter.cs -l 18 -s pending
bifrost resolve ./src/Services/EmojiStreamConverter.cs   # which server, which root, and why
bifrost servers                                          # what is configured and installed
bifrost mcp                                              # the stdio server (also the no-argument default)
bifrost read ./src/Foo.cs --offset 12 --limit 4          # tagged, numbered lines
bifrost edit < patch.txt                                 # the patch document on stdin; --dry-run, --report diff|summary|none, --json
bifrost write ./src/New.cs < New.cs                      # content on stdin; --tag to overwrite

--help works on every command; the flags and their descriptions come from the XML docs, so they cannot drift from the code.

Using it where you would use grep

grep takes grep's flags where they mean the same thing — -i -v -w -l -c -C -E — exits 0 on matches, 1 on none, 2 on error, and reads stdin when no path is given:

bifrost grep TODO src/ tests/            # PATTERN FILE... , as grep takes them
bifrost grep -i -m 3 TODO src/           # -m caps matches per file, as grep -m
git ls-files | bifrost grep lsp          # filter a pipeline ("-" also means stdin)
bifrost grep TODO src/ && echo found     # composes in a conditional

Several paths are searched together and reported relative to the closest directory containing all of them, so display paths stay short without two same-named files from different trees colliding.

Options must come before the file arguments, as with POSIX grep — grep PATTERN FILE -m 1 reads -m as a file name and says so rather than failing obscurely.

-m/--max-count is grep's per-file cap; --max is a separate total across all files. Both exist because they do different jobs: one stops a single noisy file from crowding everything else out, the other bounds the whole answer.

Output format follows the destination: grouped on a terminal, plain path:line:text when piped, so | cut -d: -f1 keeps working. Force either with --format grouped|plain.

When piped input is all paths — what git ls-files and find produce — the grouped form folds them the same way a tree search does. The check is deliberately conservative (every line needs a separator and no spaces); prose is never restructured, and the cost of declining is just a flat list.

Where it deliberately differs from grep

Worth knowing before using it as a drop-in, because a few of these are silent:

grep bifrost grep
Ignored files searched skipped — .gitignore is always respected, and there is no opt-out
Pattern regex (BRE) regex (.NET); -F for a literal
Case sensitive smart — insensitive unless the pattern has an uppercase letter
Recursion needs -r always recursive from the given directory
Line numbers needs -n always shown in content mode
Regex flavour BRE/ERE/PCRE .NET non-backtracking: no lookaround or backreferences (they raise an error rather than failing quietly)
Result cap -m per file only -m per file and --max total (200); the elided count is always printed
Option order anywhere (GNU) before the file arguments, as POSIX grep
Binary files reported skipped

The first row is the one that can mislead: a file excluded by .gitignore produces no match and no warning, which is indistinguishable from the text not being there. -p on a specific file still works, so reach for that when you need to look inside build output.

Two grep short flags are deliberately not reused, because silently meaning something else is worse than not existing: -r (grep: recursive) and -n (grep: line numbers). -s is case-sensitive here, not grep's "suppress errors".

Install

Two packages, one version, one tag:

Package What it is
Lykos.Bifrost the MCP server and the bifrost CLI, as a .NET global tool
Lykos.Bifrost.Core the engines as a library, for a host that wants them in process

From NuGet:

dotnet tool install -g Lykos.Bifrost

Lykos.Bifrost.Core carries no CLI, MCP or hosting dependency; a host references it and calls Reader.Render, EditEngine.Run, Writer.Write, CodeSearch, FileFinder and LspServerPool directly. EditEngine.Run hands back both forms of the result the MCP tool returns — the rendered echo the model reads, and the JSON payload a renderer parses — so an in-process host and a JSON-RPC one see the same edit.

dotnet add package Lykos.Bifrost.Core

From a clone (an untagged commit packs as a prerelease of the next minor, so --prerelease is needed to install it):

dotnet pack src/Bifrost -c Release -o nupkg
dotnet tool install -g Lykos.Bifrost --prerelease --add-source ./nupkg

Or as a plain publish, if you would rather point at a path:

dotnet publish src/Bifrost -c Release -o dist

Publishing to dist/ (rather than the build output) matters: the registered exe is the one Claude Code runs, and a running MCP server holds a lock on its own binary — so a rebuild while it is live fails to overwrite. dist/ is a stable release location you republish while it is stopped.

Register it (user scope, ~/.claude.json):

{
  "mcpServers": {
    "bifrost": {
      "command": "bifrost",
      "args": ["mcp"]
    }
  }
}

Or run:

# Install
claude mcp add-json bifrost '{ "command": "bifrost", "args": ["mcp"] }' --scope user

# Remove
claude mcp remove bifrost

If you installed it as a global tool, bifrost is already on PATH; otherwise use the full path to dist/bifrost.exe. A running MCP server holds a lock on its own binary, so republish while it is stopped — that is why dist/ is a stable location separate from the build output.

Language server prerequisites

dotnet tool install --global csharp-ls                         # C#
npm install -g typescript-language-server typescript@5.9        # TypeScript / JS
npm install -g pyright                                          # Python
rustup component add rust-analyzer                              # Rust (or it ships with the toolchain)

A server is only started when a matching file is opened, so a missing one costs nothing until you touch that language.

typescript@7 is the native "tsgo" rewrite and ships no lib/tsserver.js, which typescript-language-server requires. Installing plain typescript now resolves to 7.x and breaks the server with "Could not find a valid TypeScript installation" no matter how many times you reinstall. Pin ^5.9.

Releasing

A release is a tag. The version comes from it via MinVer, so nothing in the repo is bumped by hand:

git tag v0.6.0 && git push origin v0.6.0

release.yml then builds, runs the tests, packs both packages at that version, pushes them to NuGet and creates the GitHub release with the .nupkg files attached and generated notes. A tag whose tests fail publishes nothing. ci.yml builds and tests every push and pull request.

NuGet is reached through trusted publishing: no API key is stored. One-time setup: on nuget.org, under the account's Trusted Publishing, add a policy for repository owner ionite34, repository Bifrost, workflow file release.yml; then set the repository variable NUGET_USER to that nuget.org account name (gh variable set NUGET_USER --body <name>). The workflow's NuGet/login step exchanges the job's OIDC token for a short-lived key.

Configuration

Optional. Defaults for C# and TypeScript are built in, so Bifrost works with none.

The first file found wins: --config <path>, $BIFROST_CONFIG, <cwd>/.lsp.json, ~/.bifrost/lsp.json, ~/.claude/skills/lsp-local/.lsp.json.

Entries are merged field-by-field over the defaults — an omitted key keeps the default rather than clearing it. That matters: a config specifying only command used to wipe rootMarkers, leaving the server rooted at the file's own directory and answering plausible nonsense.

{
  "csharp": { "startupTimeout": 240000 },
  "python": {
    // Sent as workspace/didChangeConfiguration and used to answer workspace/configuration pulls.
    "settings": { "python": { "analysis": { "diagnosticMode": "workspace" } } }
  },
  "zig": {
    "command": "zls",
    "extensionToLanguage": { ".zig": "zig" },
    "rootMarkers": ["build.zig"]
  }
}

The server catalog (opt-in)

A bundled catalog.json carries ~50 server definitions adapted from oh-my-pi. It is off by default — the curated four are tuned in ways a generic entry is not, and most of the catalog is noise on a machine that has four servers installed. Enable with --catalog or BIFROST_CATALOG=1:

{ "mcpServers": { "bifrost": {
  "command": "F:/Repos/NET/Bifrost/dist/bifrost.exe",
  "env": { "BIFROST_CATALOG": "1" }
} } }

Two gates keep it quiet: an entry is skipped unless its command actually resolves on PATH, and unless every extension it claims is still unclaimed. Curated defaults always win, so which server answers for a file never depends on dictionary ordering.

Notes

  • stdout carries the MCP protocol and nothing else; all logging goes to stderr.
  • Servers are stopped when the bridge exits, so a csharp-ls holding a solution is never stranded.
  • A failed start is not cached — fix the environment and the next call retries, rather than replaying a stale failure for the rest of the session.
  • Requests wait on the server's own progress notifications before answering, so "still indexing" is reported as such instead of being returned as an empty result.

Windows

npm installs three shims side by side: an extensionless bash script, a .cmd, and a .ps1. Only the .cmd is executable here, and Process.Start cannot run it directly — Bifrost resolves .exe/.cmd ahead of the extensionless form and launches batch shims via cmd /c.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.13.0 111 10/26/2026
0.12.1 592 10/5/2026
0.12.0 113 10/4/2026
0.11.1 157 10/4/2026
0.11.0 107 10/4/2026
0.10.0 128 10/4/2026
0.9.0 112 9/17/2026
0.8.0 528 9/16/2026
0.7.1 102 9/15/2026