diff --git a/.github/workflows/build-and-release.yaml b/.github/workflows/build-and-release.yaml
index a3cb3b75..402a082f 100644
--- a/.github/workflows/build-and-release.yaml
+++ b/.github/workflows/build-and-release.yaml
@@ -83,6 +83,9 @@ jobs:
         run: cabal build --project-file cabal.project.release --only-dependencies
       - name: Build executables
         run: cabal build exe:jbeam-edit --project-file cabal.project.release
+      - name: Build LSP test server
+        run: |
+          cabal install exe:jbeam-lsp-test-server --project-file cabal.project.release || true
       - name: Run tests (GHC ${{ matrix.ghc }})
         if: "!startsWith(github.ref, 'refs/tags/')"
         run: cabal test --project-file cabal.project.release
diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml
index 18233b7e..0050c135 100644
--- a/.github/workflows/build-and-test.yaml
+++ b/.github/workflows/build-and-test.yaml
@@ -168,5 +168,8 @@ jobs:
             ${{ runner.os }}-cabal-${{ matrix.ghc }}-
       - name: Build project (GHC ${{ steps.setup-ghc.outputs.ghc-version }})
         run: cabal build --project-file cabal.project.ci all
+      - name: Build LSP test server
+        run: |
+          cabal install exe:jbeam-lsp-test-server --project-file cabal.project.ci || true
       - name: Run tests (GHC ${{ steps.setup-ghc.outputs.ghc-version }})
         run: cabal test --project-file cabal.project.ci
diff --git a/.github/workflows/future-proofing.yaml b/.github/workflows/future-proofing.yaml
index d142a3f7..880375c1 100644
--- a/.github/workflows/future-proofing.yaml
+++ b/.github/workflows/future-proofing.yaml
@@ -52,5 +52,8 @@ jobs:
             ${{ runner.os }}-cabal-${{ steps.get-ghc.outputs.ghc-version }}-
       - name: Build project (GHC latest)
         run: cabal build --project-file cabal.project.ci all
+      - name: Build LSP test server
+        continue-on-error: true
+        run: cabal install exe:jbeam-lsp-test-server --project-file cabal.project.ci
       - name: Run tests (GHC latest)
         run: cabal test --project-file cabal.project.ci
diff --git a/app-extra/jbeam-lsp-server/.dir-locals.el b/app-extra/jbeam-lsp-server/.dir-locals.el
new file mode 100644
index 00000000..16080c1e
--- /dev/null
+++ b/app-extra/jbeam-lsp-server/.dir-locals.el
@@ -0,0 +1,4 @@
+((haskell-mode
+  . ((haskell-process-type . cabal-repl)
+     (eval . (setq-local haskell-process-args-cabal-repl
+                         (append '("jbeam-edit:exe:jbeam-lsp-server" "--project-file" "cabal.project.dev") haskell-process-args-cabal-repl))))))
diff --git a/app-extra/jbeam-lsp-server/Main.hs b/app-extra/jbeam-lsp-server/Main.hs
new file mode 100644
index 00000000..ff6d0953
--- /dev/null
+++ b/app-extra/jbeam-lsp-server/Main.hs
@@ -0,0 +1,12 @@
+module Main (
+  main,
+) where
+
+import Server (runServer)
+
+import Formatting.Config qualified as FmtCfg
+
+main :: IO ()
+main = do
+  rs <- FmtCfg.readFormattingConfig
+  void (runServer rs)
diff --git a/cabal.project b/cabal.project
index de9ea8b2..185175cb 100644
--- a/cabal.project
+++ b/cabal.project
@@ -4,4 +4,4 @@ tests: False
 
 package jbeam-edit
   tests: False
-  flags: -dump-ast -transformation -windows-example-paths
+  flags: -dump-ast -transformation -lsp-server -windows-example-paths
diff --git a/cabal.project.ci b/cabal.project.ci
index 76c99138..83d9959e 100644
--- a/cabal.project.ci
+++ b/cabal.project.ci
@@ -6,4 +6,4 @@ program-options
 
 package jbeam-edit
   tests: True
-  flags: +dump-ast +transformation -windows-example-paths
+  flags: +dump-ast +transformation +lsp-server -windows-example-paths
diff --git a/cabal.project.dev b/cabal.project.dev
index 030d0de6..3a6cba13 100644
--- a/cabal.project.dev
+++ b/cabal.project.dev
@@ -11,4 +11,4 @@ package jbeam-edit
   tests: True
   haddock-executables: True
   haddock-internal: True
-  flags: +dump-ast +transformation -windows-example-paths
+  flags: +dump-ast +transformation +lsp-server -windows-example-paths
diff --git a/hie.yaml b/hie.yaml
index 7d406149..065dc2ee 100644
--- a/hie.yaml
+++ b/hie.yaml
@@ -14,8 +14,14 @@ cradle:
                 component: lib:jbeam-edit
               - path: ./src-extra/transformation
                 component: jbeam-edit:lib:jbeam-edit-transformation
+              - path: ./src-extra/language-server
+                component: jbeam-edit:lib:jbeam-language-server
               - path: ./app
                 component: exe:jbeam-edit
+              - path: ./app-extra/jbeam-lsp-server
+                component: jbeam-edit:exe:jbeam-lsp-server
+              - path: ./app-extra/jbeam-lsp-test-server
+                component: jbeam-edit:exe:jbeam-lsp-test-server
               - path: ./tools/dump_ast
                 component: exe:jbeam-edit-dump-ast
               - path: ./test
diff --git a/jbeam-edit.cabal b/jbeam-edit.cabal
index f7237409..c33228b2 100644
--- a/jbeam-edit.cabal
+++ b/jbeam-edit.cabal
@@ -49,6 +49,11 @@ flag dump-ast
     default:     False
     manual:      True
 
+flag lsp-server
+    description: Enable LSP server (experimental)
+    default:     False
+    manual:      True
+
 flag transformation
     description: Enable transformation (experimental)
     default:     False
@@ -152,6 +157,46 @@ library jbeam-edit-transformation
     else
         buildable: False
 
+library jbeam-language-server
+    exposed-modules:
+        Handlers.Formatting
+        Server
+        Services.DocumentStore
+
+    hs-source-dirs:     src-extra/language-server
+    other-modules:      Paths_jbeam_edit
+    autogen-modules:    Paths_jbeam_edit
+    default-language:   Haskell2010
+    default-extensions: OverloadedStrings ImportQualifiedPost
+    ghc-options:
+        -Wall -Wcompat -Widentities -Wincomplete-record-updates
+        -Wincomplete-uni-patterns -Wmissing-export-lists
+        -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+
+    build-depends:
+        base >=4.17 && <5,
+        bytestring >=0.12,
+        containers >=0.6,
+        directory >=1.3,
+        filepath >=1.4,
+        jbeam-edit,
+        megaparsec >=9.6,
+        relude >=1.2,
+        scientific >=0.3,
+        text >=2.1,
+        vector >=0.13
+
+    mixins:
+        base hiding (Prelude),
+        relude (Relude as Prelude),
+        relude
+
+    if (flag(lsp-server) && impl(ghc >=9.6.6))
+        build-depends: lsp >=2.7
+
+    else
+        buildable: False
+
 executable jbeam-edit
     main-is:            Main.hs
     hs-source-dirs:     app
@@ -232,6 +277,80 @@ executable jbeam-edit-dump-ast
     else
         buildable: False
 
+executable jbeam-lsp-server
+    main-is:            Main.hs
+    hs-source-dirs:     app-extra/jbeam-lsp-server
+    other-modules:      Paths_jbeam_edit
+    autogen-modules:    Paths_jbeam_edit
+    default-language:   Haskell2010
+    default-extensions: OverloadedStrings ImportQualifiedPost
+    ghc-options:
+        -Wall -Wcompat -Widentities -Wincomplete-record-updates
+        -Wincomplete-uni-patterns -Wmissing-export-lists
+        -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+        -threaded -rtsopts -with-rtsopts=-N
+
+    build-depends:
+        base >=4.17 && <5,
+        bytestring >=0.12,
+        containers >=0.6,
+        directory >=1.3,
+        filepath >=1.4,
+        jbeam-edit,
+        megaparsec >=9.6,
+        relude >=1.2,
+        scientific >=0.3,
+        text >=2.1,
+        vector >=0.13
+
+    mixins:
+        base hiding (Prelude),
+        relude (Relude as Prelude),
+        relude
+
+    if (flag(lsp-server) && impl(ghc >=9.6.6))
+        build-depends: jbeam-language-server
+
+    else
+        buildable: False
+
+executable jbeam-lsp-test-server
+    main-is:            Main.hs
+    hs-source-dirs:     tools/lsp-test-server
+    other-modules:      Paths_jbeam_edit
+    autogen-modules:    Paths_jbeam_edit
+    default-language:   Haskell2010
+    default-extensions: OverloadedStrings ImportQualifiedPost
+    ghc-options:
+        -Wall -Wcompat -Widentities -Wincomplete-record-updates
+        -Wincomplete-uni-patterns -Wmissing-export-lists
+        -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+        -threaded -rtsopts -with-rtsopts=-N
+
+    build-depends:
+        base >=4.17 && <5,
+        bytestring >=0.12,
+        containers >=0.6,
+        directory >=1.3,
+        filepath >=1.4,
+        jbeam-edit,
+        megaparsec >=9.6,
+        relude >=1.2,
+        scientific >=0.3,
+        text >=2.1,
+        vector >=0.13
+
+    mixins:
+        base hiding (Prelude),
+        relude (Relude as Prelude),
+        relude
+
+    if (flag(lsp-server) && impl(ghc >=9.6.6))
+        build-depends: jbeam-language-server
+
+    else
+        buildable: False
+
 test-suite jbeam-edit-test
     type:               exitcode-stdio-1.0
     main-is:            Spec.hs
@@ -247,6 +366,7 @@ test-suite jbeam-edit-test
         Parsing.JbeamSpec
         SpecHelper
         TransformationSpec
+        WorkspaceLspSpec
         Paths_jbeam_edit
 
     autogen-modules:    Paths_jbeam_edit
@@ -278,6 +398,12 @@ test-suite jbeam-edit-test
         relude (Relude as Prelude),
         relude
 
+    if (flag(lsp-server) && impl(ghc >=9.6.6))
+        cpp-options:   -DENABLE_LSP_TESTS
+        build-depends:
+            lsp >=2.7,
+            lsp-test
+
     if flag(transformation)
         cpp-options:   -DENABLE_TRANSFORMATION_TESTS
         build-depends: jbeam-edit-transformation
diff --git a/package.yaml b/package.yaml
index fad60a82..af61ce19 100644
--- a/package.yaml
+++ b/package.yaml
@@ -14,14 +14,16 @@ extra-source-files:
   - examples/jbeam/*.jbeam
   - examples/formatted_jbeam/*.jbeam
 
-
 category: Command Line, Jbeam, Beamng
-
-synopsis:
-  A fast and reliable command-line tool for parsing, formatting, and editing JBeam files, supporting consistent node renaming, reference updating, and JBFL-based formatting.
-
+synopsis: A fast and reliable command-line tool for parsing, formatting, and editing
+  JBeam files, supporting consistent node renaming, reference updating, and JBFL-based
+  formatting.
 description: >-
-  jbeam-edit is a Haskell-based CLI utility for BeamNG JBeam files. It parses complete JBeam structures, preserves comments and whitespace, formats files consistently, and can automatically rename nodes and update references. Custom formatting rules are supported via JBFL (JBeam Formatting Language). See the README for usage instructions and examples: https://github.com/webdevred/jbeam-edit#readme
+  jbeam-edit is a Haskell-based CLI utility for BeamNG JBeam files. It parses complete
+  JBeam structures, preserves comments and whitespace, formats files consistently,
+  and can automatically rename nodes and update references. Custom formatting rules
+  are supported via JBFL (JBeam Formatting Language). See the README for usage instructions
+  and examples: https://github.com/webdevred/jbeam-edit#readme
 tested-with: [GHC == 9.4.7, GHC == 9.6.6]
 
 dependencies:
@@ -62,6 +64,10 @@ flags:
     description: Enable transformation (experimental)
     manual: true
     default: false
+  lsp-server:
+    description: Enable LSP server (experimental)
+    manual: true
+    default: false
   windows-example-paths:
     description: Use executable-relative example paths (for Windows release builds)
     default: false
@@ -77,6 +83,15 @@ internal-libraries:
         buildable: false
     source-dirs: src-extra/transformation
     dependencies: [jbeam-edit]
+  jbeam-language-server:
+    when:
+      condition: flag(lsp-server) && impl(ghc >= 9.6.6)
+      then:
+        dependencies: [lsp>=2.7]
+      else:
+        buildable: false
+    source-dirs: src-extra/language-server
+    dependencies: [jbeam-edit]
 
 library:
   source-dirs: src
@@ -87,6 +102,17 @@ library:
     - condition: os(windows) && flag(windows-example-paths)
       cpp-options: -DWINDOWS_EXAMPLE_PATHS
 
+_jbeam-lsp-common: &jbeam-lsp-common
+  main: Main.hs
+  ghc-options: [-threaded, -rtsopts, -with-rtsopts=-N]
+  when:
+    condition: flag(lsp-server) && impl(ghc >= 9.6.6)
+    then:
+      dependencies: [jbeam-language-server]
+    else:
+      buildable: false
+  dependencies: [jbeam-edit]
+
 executables:
   jbeam-edit:
     main: Main.hs
@@ -99,6 +125,12 @@ executables:
         cpp-options: -DENABLE_TRANSFORMATION
       - condition: os(windows)
         cpp-options: -DENABLE_WINDOWS_NEWLINES
+  jbeam-lsp-server:
+    source-dirs: app-extra/jbeam-lsp-server
+    <<: *jbeam-lsp-common
+  jbeam-lsp-test-server:
+    source-dirs: tools/lsp-test-server
+    <<: *jbeam-lsp-common
   jbeam-edit-dump-ast:
     when:
       condition: (flag(dump-ast) && flag(transformation))
@@ -119,6 +151,9 @@ tests:
     dependencies: [jbeam-edit, hspec>=2.11, hspec-megaparsec>=2.2]
     build-tools: [hspec-discover]
     when:
-      condition: flag(transformation)
-      dependencies: [jbeam-edit-transformation]
-      cpp-options: -DENABLE_TRANSFORMATION_TESTS
+      - condition: flag(lsp-server) && impl(ghc >= 9.6.6)
+        dependencies: [lsp-test, lsp>=2.7]
+        cpp-options: -DENABLE_LSP_TESTS
+      - condition: flag(transformation)
+        dependencies: [jbeam-edit-transformation]
+        cpp-options: -DENABLE_TRANSFORMATION_TESTS
diff --git a/src-extra/language-server/.dir-locals.el b/src-extra/language-server/.dir-locals.el
new file mode 100644
index 00000000..4ea4e65a
--- /dev/null
+++ b/src-extra/language-server/.dir-locals.el
@@ -0,0 +1,4 @@
+((haskell-mode
+  . ((haskell-process-type . cabal-repl)
+     (eval . (setq-local haskell-process-args-cabal-repl
+                         (append '("jbeam-edit:lib:jbeam-language-server" "--project-file" "cabal.project.dev") haskell-process-args-cabal-repl))))))
diff --git a/src-extra/language-server/Handlers/Formatting.hs b/src-extra/language-server/Handlers/Formatting.hs
new file mode 100644
index 00000000..db341a0a
--- /dev/null
+++ b/src-extra/language-server/Handlers/Formatting.hs
@@ -0,0 +1,97 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE PolyKinds #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+
+module Handlers.Formatting (handlers) where
+
+import Core.Node (Node)
+import Formatting.Rules (RuleSet)
+import IOUtils
+
+import Data.Text qualified as T
+import Formatting qualified as Fmt
+import Language.LSP.Protocol.Message qualified as Msg
+import Language.LSP.Protocol.Types qualified as J (
+  DocumentFormattingParams (..),
+  Null (..),
+  Position (..),
+  Range (..),
+  TextDocumentIdentifier (..),
+  TextEdit (..),
+  type (|?) (..),
+ )
+import Language.LSP.Server qualified as S
+import Parsing.Jbeam qualified as JbeamP
+import Services.DocumentStore qualified as Docs
+
+putErrorLine' :: MonadIO m => Text -> m ()
+putErrorLine' = liftIO . putErrorLine
+
+handlers :: RuleSet -> S.Handlers (S.LspM config)
+handlers rs =
+  S.requestHandler Msg.SMethod_TextDocumentFormatting formattingHandler
+  where
+    formattingHandler
+      :: Msg.TRequestMessage Msg.Method_TextDocumentFormatting
+      -> ( Either
+             (Msg.TResponseError Msg.Method_TextDocumentFormatting)
+             (Msg.MessageResult Msg.Method_TextDocumentFormatting)
+           -> S.LspM config ()
+         )
+      -> S.LspM config ()
+    formattingHandler req responder = do
+      putErrorLine' "DEBUG: formattingHandler invoked"
+      let Msg.TRequestMessage _ _ _ (params :: J.DocumentFormattingParams) = req
+      handleParams rs params responder
+
+handleParams
+  :: RuleSet
+  -> J.DocumentFormattingParams
+  -> ( Either
+         (Msg.TResponseError Msg.Method_TextDocumentFormatting)
+         (Msg.MessageResult Msg.Method_TextDocumentFormatting)
+       -> S.LspM config ()
+     )
+  -> S.LspM config ()
+handleParams rs params responder = do
+  let J.DocumentFormattingParams {J._textDocument = textDocId} = params
+      J.TextDocumentIdentifier {J._uri = uri} = textDocId
+  mText <- liftIO $ Docs.get uri
+  case mText of
+    Nothing -> do
+      putErrorLine' ("DEBUG: no document in store for " <> show uri)
+      responder (Right (J.InR J.Null))
+    Just txt ->
+      case JbeamP.parseNodes (encodeUtf8 txt) of
+        Left err -> do
+          liftIO . putErrorLine' $ "Parse error: " <> show err
+          responder (Right (J.InR J.Null))
+        Right node -> runFormatNode responder rs txt node
+
+runFormatNode
+  :: (Either a ([J.TextEdit] J.|? J.Null) -> t)
+  -> RuleSet
+  -> Text
+  -> Node
+  -> t
+runFormatNode responder ruleSet txt node =
+  let newText = Fmt.formatNode ruleSet node
+      edit = J.TextEdit {J._range = wholeRange txt, J._newText = newText}
+   in if newText == txt
+        then
+          responder (Right (J.InR J.Null))
+        else
+          responder (Right (J.InL [edit]))
+
+wholeRange :: Text -> J.Range
+wholeRange txt =
+  let ls = lines txt
+      numLines = max 1 (length ls)
+      lastLineLen =
+        case reverse ls of
+          [] -> 0
+          (lastLine : _) -> T.length lastLine
+   in J.Range
+        (J.Position 0 0)
+        (J.Position (fromIntegral numLines) (fromIntegral lastLineLen))
diff --git a/src-extra/language-server/Server.hs b/src-extra/language-server/Server.hs
new file mode 100644
index 00000000..c165127f
--- /dev/null
+++ b/src-extra/language-server/Server.hs
@@ -0,0 +1,112 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE PolyKinds #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeOperators #-}
+
+module Server (runServer) where
+
+import Formatting.Rules (RuleSet)
+import IOUtils
+
+import Handlers.Formatting qualified as Formatting
+import Language.LSP.Protocol.Message qualified as Msg
+import Language.LSP.Protocol.Types qualified as J (
+  DidChangeTextDocumentParams (..),
+  DidCloseTextDocumentParams (..),
+  DidOpenTextDocumentParams (..),
+  TextDocumentContentChangeEvent (..),
+  TextDocumentContentChangePartial (..),
+  TextDocumentContentChangeWholeDocument (..),
+  TextDocumentIdentifier (..),
+  TextDocumentItem (..),
+  TextDocumentSyncKind (..),
+  TextDocumentSyncOptions (..),
+  VersionedTextDocumentIdentifier (..),
+  type (|?) (..),
+ )
+import Language.LSP.Server qualified as S
+import Services.DocumentStore qualified as Docs
+
+staticHandlers :: RuleSet -> S.Handlers (S.LspM config)
+staticHandlers rs =
+  mconcat
+    [ S.notificationHandler Msg.SMethod_Initialized $ \_notif ->
+        liftIO $ putErrorLine "Client initialized"
+    , S.notificationHandler Msg.SMethod_WorkspaceDidChangeConfiguration $ \_notif ->
+        liftIO $ putErrorLine "Configuration changed"
+    , S.notificationHandler Msg.SMethod_TextDocumentDidOpen handleDidOpen
+    , S.notificationHandler Msg.SMethod_TextDocumentDidClose handleDidClose
+    , S.notificationHandler Msg.SMethod_TextDocumentDidChange handleDidChange
+    ]
+    <> Formatting.handlers rs
+
+-- | Starta LSP-servern
+runServer :: RuleSet -> IO Int
+runServer rs =
+  S.runServer $
+    S.ServerDefinition
+      { configSection = "jbeam-lsp"
+      , parseConfig = \_ _ -> Right ()
+      , onConfigChange = const >> pure $ pass
+      , defaultConfig = ()
+      , doInitialize = \env _req -> pure (Right env)
+      , staticHandlers = const $ staticHandlers rs
+      , interpretHandler = \env -> S.Iso (S.runLspT env) liftIO
+      , options =
+          S.defaultOptions
+            { S.optTextDocumentSync =
+                Just
+                  ( J.TextDocumentSyncOptions
+                      { J._openClose = Just True
+                      , J._change = Just J.TextDocumentSyncKind_Full
+                      , J._willSave = Nothing
+                      , J._willSaveWaitUntil = Nothing
+                      , J._save = Nothing
+                      }
+                  )
+            }
+      }
+
+-- | didOpen: save docuement in DocumentStore
+handleDidOpen
+  :: forall
+    {f :: Msg.MessageDirection}
+    {m1 :: Msg.Method f Msg.Notification}
+    {m2 :: Type -> Type}
+   . (MonadIO m2, Msg.MessageParams m1 ~ J.DidOpenTextDocumentParams)
+  => Msg.TNotificationMessage m1 -> m2 ()
+handleDidOpen (Msg.TNotificationMessage _ _ (J.DidOpenTextDocumentParams textDoc)) =
+  let J.TextDocumentItem {J._uri = uri, J._text = txt} = textDoc
+   in liftIO $ Docs.open uri txt
+
+-- | didChange: update document in DocumentStore
+handleDidChange
+  :: forall
+    {f :: Msg.MessageDirection}
+    {m1 :: Msg.Method f Msg.Notification}
+    {m2 :: Type -> Type}
+   . ( MonadIO m2
+     , Msg.MessageParams m1 ~ J.DidChangeTextDocumentParams
+     )
+  => Msg.TNotificationMessage m1 -> m2 ()
+handleDidChange (Msg.TNotificationMessage _ _ (J.DidChangeTextDocumentParams docId changes)) =
+  let J.VersionedTextDocumentIdentifier {_uri = uri} = docId
+   in case changes of
+        (J.TextDocumentContentChangeEvent change : _) ->
+          case change of
+            J.InL (J.TextDocumentContentChangePartial {J._text = txt}) -> liftIO $ Docs.update uri txt
+            J.InR (J.TextDocumentContentChangeWholeDocument txt) -> liftIO $ Docs.update uri txt
+        _ -> pass
+
+handleDidClose
+  :: forall
+    {f :: Msg.MessageDirection}
+    {m1 :: Msg.Method f Msg.Notification}
+    {m2 :: Type -> Type}
+   . ( MonadIO m2
+     , Msg.MessageParams m1 ~ J.DidCloseTextDocumentParams
+     )
+  => Msg.TNotificationMessage m1 -> m2 ()
+handleDidClose (Msg.TNotificationMessage _ _ (J.DidCloseTextDocumentParams docId)) =
+  let J.TextDocumentIdentifier {_uri = uri} = docId in liftIO (Docs.delete uri)
diff --git a/src-extra/language-server/Services/DocumentStore.hs b/src-extra/language-server/Services/DocumentStore.hs
new file mode 100644
index 00000000..caf7dc23
--- /dev/null
+++ b/src-extra/language-server/Services/DocumentStore.hs
@@ -0,0 +1,30 @@
+module Services.DocumentStore (open, update, get, delete) where
+
+import Control.Concurrent.MVar hiding (newMVar, readMVar)
+import IOUtils
+import Language.LSP.Protocol.Types (Uri)
+import System.IO.Unsafe (unsafePerformIO)
+import Prelude hiding (get)
+
+import Data.Map.Strict qualified as M
+import Data.Text qualified as T
+
+type DocumentStore = MVar (M.Map Uri T.Text)
+
+{-# NOINLINE store #-}
+store :: DocumentStore
+store = unsafePerformIO $ do
+  putErrorLine "[Info] Initializing DocumentStore"
+  newMVar M.empty
+
+open :: Uri -> T.Text -> IO ()
+open uri text = modifyMVar_ store (pure . M.insert uri text)
+
+update :: Uri -> T.Text -> IO ()
+update = open
+
+get :: Uri -> IO (Maybe T.Text)
+get uri = M.lookup uri <$> readMVar store
+
+delete :: Uri -> IO ()
+delete uri = modifyMVar_ store (pure . M.delete uri)
diff --git a/test/WorkspaceLspSpec.hs b/test/WorkspaceLspSpec.hs
new file mode 100644
index 00000000..b477bf5a
--- /dev/null
+++ b/test/WorkspaceLspSpec.hs
@@ -0,0 +1,33 @@
+{-# LANGUAGE CPP #-}
+
+module WorkspaceLspSpec (spec) where
+
+import Test.Hspec
+
+#ifdef ENABLE_LSP_TESTS
+import Language.LSP.Test
+import Language.LSP.Protocol.Types as LSP
+import qualified Data.Text.IO as T
+import System.FilePath ((</>))
+
+spec :: Spec
+spec = describe "JBeam LSP Formatter" $ do
+  it "formats a single JBeam file with a single JBFL rule correctly" $ do
+    runSession ("jbeam-lsp-test-server " <> ("examples" </> "ast" </> "jbfl" </> "minimal.hs")) fullLatestClientCaps "examples" $ do
+      let
+        jbeamFile    = "jbeam" </> "fender.jbeam"
+        expectedFile = "examples" </> "formatted_jbeam" </> "fender-minimal-jbfl.jbeam"
+
+      doc <- openDoc jbeamFile "jbeam"
+
+      formatDoc doc (LSP.FormattingOptions 0 False Nothing Nothing Nothing)
+
+      formatted <- documentContents doc
+      expected  <- liftIO $ T.readFile expectedFile
+
+      liftIO $ formatted `shouldBe` expected
+
+#else
+spec :: Spec
+spec = pass
+#endif
diff --git a/tools/lsp-test-server/.dir-locals.el b/tools/lsp-test-server/.dir-locals.el
new file mode 100644
index 00000000..e620035b
--- /dev/null
+++ b/tools/lsp-test-server/.dir-locals.el
@@ -0,0 +1,4 @@
+((haskell-mode
+  . ((haskell-process-type . cabal-repl)
+     (eval . (setq-local haskell-process-args-cabal-repl
+                         (append '("jbeam-edit:lib:jbeam-lsp-test-server" "--project-file" "cabal.project.dev") haskell-process-args-cabal-repl))))))
diff --git a/tools/lsp-test-server/Main.hs b/tools/lsp-test-server/Main.hs
new file mode 100644
index 00000000..b414102f
--- /dev/null
+++ b/tools/lsp-test-server/Main.hs
@@ -0,0 +1,18 @@
+module Main (main) where
+
+import Relude.Unsafe (read)
+import Server (runServer)
+
+import System.IO qualified as IO (readFile)
+
+main :: IO ()
+main = do
+  args <- getArgs
+  jbflPath <- case args of
+    (p : _) -> pure p
+    [] -> do
+      putStrLn "Usage: test-server <path-to-jbfl>"
+      exitFailure
+
+  ruleSet <- read <$> IO.readFile jbflPath
+  void $ runServer ruleSet
