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
<PackageReference Include="Lykos.Bifrost.Core" Version="0.13.0" />
<PackageVersion Include="Lykos.Bifrost.Core" Version="0.13.0" />
<PackageReference Include="Lykos.Bifrost.Core" />
paket add Lykos.Bifrost.Core --version 0.13.0
#r "nuget: Lykos.Bifrost.Core, 0.13.0"
#:package Lykos.Bifrost.Core@0.13.0
#addin nuget:?package=Lykos.Bifrost.Core&version=0.13.0
#tool nuget:?package=Lykos.Bifrost.Core&version=0.13.0
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@7is the native "tsgo" rewrite and ships nolib/tsserver.js, whichtypescript-language-serverrequires. Installing plaintypescriptnow 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-lsholding 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 | Versions 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. |
-
net10.0
- Glacier.Grep (>= 1.0.0)
- System.IO.Hashing (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.