# Directory Structure
```
├── .gitignore
├── assets
│ └── lokka-demo-1.gif
├── LICENSE
├── README.md
├── src
│ └── mcp
│ ├── build
│ │ ├── auth.js
│ │ ├── constants.js
│ │ ├── logger.js
│ │ └── main.js
│ ├── package-lock.json
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── auth.ts
│ │ ├── constants.ts
│ │ ├── logger.ts
│ │ └── main.ts
│ ├── TESTING.md
│ └── tsconfig.json
└── website
├── .gitignore
├── blog
│ ├── 2019-05-28-first-blog-post.md
│ ├── 2019-05-29-long-blog-post.md
│ ├── 2021-08-01-mdx-blog-post.mdx
│ ├── 2021-08-26-welcome
│ │ ├── docusaurus-plushie-banner.jpeg
│ │ └── index.md
│ ├── authors.yml
│ └── tags.yml
├── docs
│ ├── assets
│ │ └── how-does-lokka-mcp-server-work.png
│ ├── developer-guide.md
│ ├── faq.md
│ ├── install-advanced
│ │ ├── app-only-auth.md
│ │ ├── interactive-auth.md
│ │ ├── readme.md
│ │ └── token-auth.md
│ ├── install.mdx
│ └── intro.md
├── docusaurus.config.js
├── package-lock.json
├── package.json
├── README.md
├── sidebars.js
├── src
│ ├── css
│ │ └── custom.css
│ └── pages
│ ├── index.js
│ ├── index.module.css
│ └── styles.module.css
└── static
├── .nojekyll
└── img
├── docusaurus-social-card.png
├── docusaurus.png
├── favicon.ico
├── logo.svg
└── lokka-intro-video.png
```
# Files
--------------------------------------------------------------------------------
/website/static/.nojekyll:
--------------------------------------------------------------------------------
```
```
--------------------------------------------------------------------------------
/website/.gitignore:
--------------------------------------------------------------------------------
```
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
src/.DS_Store
.DS_Store
website/static/img/Thumbs.db
```
--------------------------------------------------------------------------------
/website/docs/install-advanced/readme.md:
--------------------------------------------------------------------------------
```markdown
---
title: 🛠️ Advanced install
sidebar_position: 3
---
# Advanced Install Guide
Use this guide if you want to configure Lokka with advanced options or use a custom Microsoft Entra application.
The quick start guide is sufficient for most users and you can find it [here](/docs/install).
Lokka supports multiple authentication options. Here's a quick summary of all the options.
- 1️⃣ → [Interactive Auth](interactive-auth)
- Interactive auth with default app
- Interactive auth with custom app
- 2️⃣ → [App-Only Auth](app-only-auth)
- App-Only Auth with Certificate
- App-Only Auth with Client Secret
- 3️⃣ → [Token Auth](token-auth)
```
--------------------------------------------------------------------------------
/website/README.md:
--------------------------------------------------------------------------------
```markdown
# Website
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
Using SSH:
```
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
```
--------------------------------------------------------------------------------
/src/mcp/README.md:
--------------------------------------------------------------------------------
```markdown
# Lokka
[](https://badge.fury.io/js/@merill%2Flokka)
Lokka is a model-context-protocol server for the Microsoft Graph and Azure RM APIs that allows you to query and manage your Azure and Microsoft 365 tenants with AI.
<img src="https://github.com/merill/lokka/blob/main/assets/lokka-demo-1.gif?raw=true" alt="Lokka Demo - user create demo" width="500"/>
Please see [Lokka.dev](https://lokka.dev) for how to use Lokka with your favorite AI model and chat client.
Lokka lets you use Claude Desktop, or any MCP Client, to use natural language to accomplish things in your Azure and Microsoft 365 tenant through the Microsoft APIs.
e.g.:
- `Create a new security group called 'Sales and HR' with a dynamic rule based on the department attribute.`
- `Find all the conditional access policies that haven't excluded the emergency access account`
- `Show me all the Intune device configuration policies assigned to the 'Call center' group`
- `What was the most expensive service in Azure last month?`

## Authentication Methods
Lokka now supports multiple authentication methods to accommodate different deployment scenarios:
### Interactive Auth
For user-based authentication with interactive login, you can use the following configuration:
This is the simplest config and uses the default Lokka app.
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"]
}
}
}
```
#### Interactive auth with custom app
If you wish to use a custom Microsoft Entra app, you can create a new app registration in Microsoft Entra and configure it with the following environment variables:
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"USE_INTERACTIVE": "true"
}
}
}
}
```
### App-Only Auth
Traditional app-only authentication. You can use either certificate (recommended) or client secret authentication with the following configuration.
See [Install Guide](https://lokka.dev/docs/install) for more details on how to create an Entra app.
#### App-Only Auth with Certificate
App only authentication using a PEM-encoded client certificate:
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CERTIFICATE_PATH": "/path/to/certificate.pem",
"CERTIFICATE_PASSWORD": "<optional-certificate-password>",
"USE_CERTIFICATE": "true"
}
}
}
}
```
For comfort, in order to convert a PFX client certificate to a PEM-encoded certificate:
```bash
openssl pkcs12 -in /path/to/cert.pfx -out /path/to/cert.pem -nodes -clcerts
```
#### #### App-Only Auth with Client Secret
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
}
```
### Client-Provided Token
Token-based authentication where the MCP Client provides access tokens:
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"USE_CLIENT_TOKEN": "true"
}
}
}
}
```
When using client-provided token mode:
1. Start the MCP server with `USE_CLIENT_TOKEN=true`
2. Use the `set-access-token` tool to provide a valid Microsoft Graph access token
3. Use the `get-auth-status` tool to verify authentication status
4. Refresh tokens as needed using `set-access-token`
## New Tools
### Token Management Tools
- **`set-access-token`**: Set or update access tokens for Microsoft Graph authentication
- **`get-auth-status`**: Check current authentication status and capabilities
- **`add-graph-permission`**: Request additional Microsoft Graph permission scopes interactively
### Graph API Version Control
Lokka now supports controlling the default Microsoft Graph API version used for all requests:
- **Default behavior**: Uses `beta` version for access to latest features
- **Production mode**: Set `USE_GRAPH_BETA=false` to force all requests to use `v1.0` version
- **Per-request override**: You can still specify `graphApiVersion` parameter in individual requests (unless `USE_GRAPH_BETA=false`)
When `USE_GRAPH_BETA=false`, all Graph API calls will use the stable `v1.0` version, even if `beta` is explicitly requested in the `graphApiVersion` parameter.
## Getting started
See the docs for more information on how to install and configure Lokka.
- [Introduction](https://lokka.dev/)
- [Install guide](https://lokka.dev/docs/install)
- [Developer guide](https://lokka.dev/docs/developer-guide)
## Components
### Tools
1. `Lokka-Microsoft`
- Call Microsoft Graph & Azure APIs. Supports querying Azure and Microsoft 365 tenants. Updates are also supported if permissions are provided.
- Input:
- `apiType` (string): Type of Microsoft API to query. Options: 'graph' for Microsoft Graph (Entra) or 'azure' for Azure Resource Management.
- `path` (string): The Azure or Graph API URL path to call (e.g. '/users', '/groups', '/subscriptions').
- `method` (string): HTTP method to use (e.g., get, post, put, patch, delete)
- `apiVersion` (string): Azure Resource Management API version (required for apiType Azure)
- `subscriptionId` (string): Azure Subscription ID (for Azure Resource Management).
- `queryParams` (string): Array of query parameters like $filter, $select, etc. All parameters are strings.
- `body` (JSON): The request body (for POST, PUT, PATCH)
- Returns: Results from the Azure or Graph API call.
2. `set-access-token` *(New in v0.2.0)*
- Set or update an access token for Microsoft Graph authentication when using client-provided token mode.
- Input:
- `accessToken` (string): The access token obtained from Microsoft Graph authentication
- `expiresOn` (string, optional): Token expiration time in ISO format
- Returns: Confirmation of token update
3. `get-auth-status` *(New in v0.2.0)*
- Check the current authentication status and mode of the MCP Server
- Returns: Authentication mode, readiness status, and capabilities
### Environment Variables
The configuration of the server is done using environment variables. The following environment variables are supported:
| Name | Description | Required |
|------|-------------|----------|
| `TENANT_ID` | The ID of the Microsoft Entra tenant. | Yes (except for client-provided token mode) |
| `CLIENT_ID` | The ID of the application registered in Microsoft Entra. | Yes (except for client-provided token mode) |
| `CLIENT_SECRET` | The client secret of the application registered in Microsoft Entra. | Yes (for client credentials mode only) |
| `USE_INTERACTIVE` | Set to "true" to enable interactive authentication mode. | No |
| `USE_CLIENT_TOKEN` | Set to "true" to enable client-provided token authentication mode. | No |
| `USE_CERTIFICATE` | Set to "true" to enable certificate authentication mode. | No |
| `CERTIFICATE_PATH` | Path to the PEM-encoded certificate file for certificate authentication. | Yes (for certificate mode only) |
| `CERTIFICATE_PASSWORD` | Password for the certificate file (if encrypted). | No |
| `REDIRECT_URI` | Redirect URI for interactive authentication (default: `http://localhost:3200`). | No |
| `ACCESS_TOKEN` | Initial access token for client-provided token mode. | No |
| `USE_GRAPH_BETA` | Set to "false" to force all Graph API calls to use v1.0 instead of beta (default: true, allows beta). | No |
## Contributors
- Interactive and Token-based Authentication (v0.2.0) - [@darrenjrobinson](https://github.com/darrenjrobinson)
- Certificate Authentication (v0.2.1) - [@nitzpo](https://github.com/nitzpo)
## Installation
To use this server with the Claude Desktop app, add the following configuration to the "mcpServers" section of your
`claude_desktop_config.json`:
### Interactive Authentication
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"]
}
}
}
```
### Client Credentials Authentication
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
}
```
Make sure to replace `<tenant-id>`, `<client-id>`, and `<client-secret>` with the actual values from your Microsoft Entra application. (See [Install Guide](https://lokka.dev/docs/install) for more details on how to create an Entra app and configure the agent.)
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Lokka
[](https://badge.fury.io/js/@merill%2Flokka)
Lokka is a model-context-protocol server for the Microsoft Graph and Azure RM APIs that allows you to query and manage your Azure and Microsoft 365 tenants with AI.
<img src="https://github.com/merill/lokka/blob/main/assets/lokka-demo-1.gif?raw=true" alt="Lokka Demo - user create demo" width="500"/>
Please see [Lokka.dev](https://lokka.dev) for how to use Lokka with your favorite AI model and chat client.
Lokka lets you use Claude Desktop, or any MCP Client, to use natural language to accomplish things in your Azure and Microsoft 365 tenant through the Microsoft APIs.
e.g.:
- `Create a new security group called 'Sales and HR' with a dynamic rule based on the department attribute.`
- `Find all the conditional access policies that haven't excluded the emergency access account`
- `Show me all the Intune device configuration policies assigned to the 'Call center' group`
- `What was the most expensive service in Azure last month?`

## Authentication Methods
Lokka now supports multiple authentication methods to accommodate different deployment scenarios:
### Interactive Auth
For user-based authentication with interactive login, you can use the following configuration:
This is the simplest config and uses the default Lokka app.
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"]
}
}
}
```
#### Interactive auth with custom app
If you wish to use a custom Microsoft Entra app, you can create a new app registration in Microsoft Entra and configure it with the following environment variables:
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"USE_INTERACTIVE": "true"
}
}
}
}
```
### App-Only Auth
Traditional app-only authentication. You can use either certificate (recommended) or client secret authentication with the following configuration.
See [Install Guide](https://lokka.dev/docs/install) for more details on how to create an Entra app.
#### App-Only Auth with Certificate
App only authentication using a PEM-encoded client certificate:
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CERTIFICATE_PATH": "/path/to/certificate.pem",
"CERTIFICATE_PASSWORD": "<optional-certificate-password>",
"USE_CERTIFICATE": "true"
}
}
}
}
```
For comfort, in order to convert a PFX client certificate to a PEM-encoded certificate:
```bash
openssl pkcs12 -in /path/to/cert.pfx -out /path/to/cert.pem -nodes -clcerts
```
#### App-Only Auth with Client Secret
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
}
```
### Client-Provided Token
Token-based authentication where the MCP Client provides access tokens:
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"USE_CLIENT_TOKEN": "true"
}
}
}
}
```
When using client-provided token mode:
1. Start the MCP server with `USE_CLIENT_TOKEN=true`
2. Use the `set-access-token` tool to provide a valid Microsoft Graph access token
3. Use the `get-auth-status` tool to verify authentication status
4. Refresh tokens as needed using `set-access-token`
## New Tools
### Token Management Tools
- **`set-access-token`**: Set or update access tokens for Microsoft Graph authentication
- **`get-auth-status`**: Check current authentication status and capabilities
- **`add-graph-permission`**: Request additional Microsoft Graph permission scopes interactively
### Graph API Version Control
Lokka now supports controlling the default Microsoft Graph API version used for all requests:
- **Default behavior**: Uses `beta` version for access to latest features
- **Production mode**: Set `USE_GRAPH_BETA=false` to force all requests to use `v1.0` version
- **Per-request override**: You can still specify `graphApiVersion` parameter in individual requests (unless `USE_GRAPH_BETA=false`)
When `USE_GRAPH_BETA=false`, all Graph API calls will use the stable `v1.0` version, even if `beta` is explicitly requested in the `graphApiVersion` parameter.
## Getting started
See the docs for more information on how to install and configure Lokka.
- [Introduction](https://lokka.dev/)
- [Install guide](https://lokka.dev/docs/install)
- [Developer guide](https://lokka.dev/docs/developer-guide)
### One-click install for VS Code
| Platform | VS Code | VS Code Insiders |
| - | - | - |
| Windows | [](vscode:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22cmd%22%2C%22args%22%3A%5B%22%2Fc%22%2C%22npx%22%2C%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) | [](vscode-insiders:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22cmd%22%2C%22args%22%3A%5B%22%2Fc%22%2C%22npx%22%2C%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) |
| macOS/Linux | [](vscode:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) | [](vscode-insiders:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) |
## Components
### Tools
1. `Lokka-Microsoft`
- Call Microsoft Graph & Azure APIs. Supports querying Azure and Microsoft 365 tenants. Updates are also supported if permissions are provided.
- Input:
- `apiType` (string): Type of Microsoft API to query. Options: 'graph' for Microsoft Graph (Entra) or 'azure' for Azure Resource Management.
- `path` (string): The Azure or Graph API URL path to call (e.g. '/users', '/groups', '/subscriptions').
- `method` (string): HTTP method to use (e.g., get, post, put, patch, delete)
- `apiVersion` (string): Azure Resource Management API version (required for apiType Azure)
- `subscriptionId` (string): Azure Subscription ID (for Azure Resource Management).
- `queryParams` (string): Array of query parameters like $filter, $select, etc. All parameters are strings.
- `body` (JSON): The request body (for POST, PUT, PATCH)
- Returns: Results from the Azure or Graph API call.
2. `set-access-token` *(New in v0.2.0)*
- Set or update an access token for Microsoft Graph authentication when using client-provided token mode.
- Input:
- `accessToken` (string): The access token obtained from Microsoft Graph authentication
- `expiresOn` (string, optional): Token expiration time in ISO format
- Returns: Confirmation of token update
3. `get-auth-status` *(New in v0.2.0)*
- Check the current authentication status and mode of the MCP Server
- Returns: Authentication mode, readiness status, and capabilities
### Environment Variables
The configuration of the server is done using environment variables. The following environment variables are supported:
| Name | Description | Required |
|------|-------------|----------|
| `TENANT_ID` | The ID of the Microsoft Entra tenant. | Yes (except for client-provided token mode) |
| `CLIENT_ID` | The ID of the application registered in Microsoft Entra. | Yes (except for client-provided token mode) |
| `CLIENT_SECRET` | The client secret of the application registered in Microsoft Entra. | Yes (for client credentials mode only) |
| `USE_INTERACTIVE` | Set to "true" to enable interactive authentication mode. | No |
| `USE_CLIENT_TOKEN` | Set to "true" to enable client-provided token authentication mode. | No |
| `USE_CERTIFICATE` | Set to "true" to enable certificate authentication mode. | No |
| `CERTIFICATE_PATH` | Path to the PEM-encoded certificate file for certificate authentication. | Yes (for certificate mode only) |
| `CERTIFICATE_PASSWORD` | Password for the certificate file (if encrypted). | No |
| `REDIRECT_URI` | Redirect URI for interactive authentication (default: `http://localhost:3000`). | No |
| `ACCESS_TOKEN` | Initial access token for client-provided token mode. | No |
| `USE_GRAPH_BETA` | Set to "false" to force all Graph API calls to use v1.0 instead of beta (default: true, allows beta). | No |
## Contributors
- Interactive and Token-based Authentication (v0.2.0) - [@darrenjrobinson](https://github.com/darrenjrobinson)
- Certificate Authentication (v0.2.1) - [@nitzpo](https://github.com/nitzpo)
## Installation
To use this server with the Claude Desktop app, add the following configuration to the "mcpServers" section of your
`claude_desktop_config.json`:
### Interactive Authentication
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"]
}
}
}
```
### Client Credentials Authentication
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
}
```
Make sure to replace `<tenant-id>`, `<client-id>`, and `<client-secret>` with the actual values from your Microsoft Entra application. (See [Install Guide](https://lokka.dev/docs/install) for more details on how to create an Entra app and configure the agent.)
```
--------------------------------------------------------------------------------
/website/blog/2019-05-28-first-blog-post.md:
--------------------------------------------------------------------------------
```markdown
---
slug: first-blog-post
title: First Blog Post
authors: [slorber, yangshun]
tags: [hola, docusaurus]
---
Lorem ipsum dolor sit amet...
<!-- truncate -->
...consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
```
--------------------------------------------------------------------------------
/website/blog/tags.yml:
--------------------------------------------------------------------------------
```yaml
facebook:
label: Facebook
permalink: /facebook
description: Facebook tag description
hello:
label: Hello
permalink: /hello
description: Hello tag description
docusaurus:
label: Docusaurus
permalink: /docusaurus
description: Docusaurus tag description
hola:
label: Hola
permalink: /hola
description: Hola tag description
```
--------------------------------------------------------------------------------
/website/src/pages/index.module.css:
--------------------------------------------------------------------------------
```css
/**
* CSS files with the .module.css suffix will be treated as CSS modules
* and scoped locally.
*/
.heroBanner {
padding: 4rem 0;
text-align: center;
position: relative;
overflow: hidden;
}
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2rem;
}
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
}
```
--------------------------------------------------------------------------------
/src/mcp/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/src/mcp/src/constants.ts:
--------------------------------------------------------------------------------
```typescript
// Shared constants for the Lokka MCP Server
export const LokkaClientId = "a9bac4c3-af0d-4292-9453-9da89e390140";
export const LokkaDefaultTenantId = "common";
export const LokkaDefaultRedirectUri = "http://localhost:3000";
// Default Graph API version based on USE_GRAPH_BETA environment variable
export const getDefaultGraphApiVersion = (): "v1.0" | "beta" => {
return process.env.USE_GRAPH_BETA !== 'false' ? "beta" : "v1.0";
};
```
--------------------------------------------------------------------------------
/website/blog/2021-08-01-mdx-blog-post.mdx:
--------------------------------------------------------------------------------
```markdown
---
slug: mdx-blog-post
title: MDX Blog Post
authors: [slorber]
tags: [docusaurus]
---
Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
:::tip
Use the power of React to create interactive blog posts.
:::
{/* truncate */}
For example, use JSX to create an interactive button:
```js
<button onClick={() => alert('button clicked!')}>Click me!</button>
```
<button onClick={() => alert('button clicked!')}>Click me!</button>
```
--------------------------------------------------------------------------------
/website/blog/authors.yml:
--------------------------------------------------------------------------------
```yaml
yangshun:
name: Yangshun Tay
title: Front End Engineer @ Facebook
url: https://github.com/yangshun
image_url: https://github.com/yangshun.png
page: true
socials:
x: yangshunz
github: yangshun
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
page:
# customize the url of the author page at /blog/authors/<permalink>
permalink: '/all-sebastien-lorber-articles'
socials:
x: sebastienlorber
linkedin: sebastienlorber
github: slorber
newsletter: https://thisweekinreact.com
```
--------------------------------------------------------------------------------
/website/blog/2021-08-26-welcome/index.md:
--------------------------------------------------------------------------------
```markdown
---
slug: welcome
title: Welcome
authors: [slorber, yangshun]
tags: [facebook, hello, docusaurus]
---
[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
Here are a few tips you might find useful.
<!-- truncate -->
Simply add Markdown files (or folders) to the `blog` directory.
Regular blog authors can be added to `authors.yml`.
The blog post date can be extracted from filenames, such as:
- `2019-05-30-welcome.md`
- `2019-05-30-welcome/index.md`
A blog post folder can be convenient to co-locate blog post images:

The blog supports tags as well!
**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.
```
--------------------------------------------------------------------------------
/website/sidebars.js:
--------------------------------------------------------------------------------
```javascript
// @ts-check
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
@type {import('@docusaurus/plugin-content-docs').SidebarsConfig}
*/
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
siteSidebar: [{type: 'autogenerated', dirName: '.'}],
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
{
type: 'category',
label: 'Tutorial',
items: ['tutorial-basics/create-a-document'],
},
],
*/
};
export default sidebars;
```
--------------------------------------------------------------------------------
/src/mcp/TESTING.md:
--------------------------------------------------------------------------------
```markdown
# How to Start Lokka MCP Server Locally and Test Microsoft Graph
This guide shows you how to start the Lokka MCP Server locally and test it with real Microsoft Graph API requests.
## Prerequisites
1. **Node.js** installed (v16 or later)
2. **Valid Microsoft Graph access token** (see below for how to get one)
3. **Build the project**: `npm run build`
## Getting an Access Token
### Option 1: Azure CLI (Easiest)
```bash
# Login to Azure CLI
az login
# Get a token for Microsoft Graph
az account get-access-token --resource https://graph.microsowft.com --query accessToken -o tsv
```
### Option 2: Graph Explorer (Quick Testing)
1. Go to https://developer.microsoft.com/en-us/graph/graph-explorer
2. Sign in with your Microsoft account
3. Open browser developer tools (F12)
4. Go to Network tab
5. Make any Graph request (like GET /me)
6. Find the request in Network tab
7. Copy the Authorization header value (remove "Bearer " prefix)
```
--------------------------------------------------------------------------------
/src/mcp/src/logger.ts:
--------------------------------------------------------------------------------
```typescript
import { appendFileSync } from "fs";
import { join } from "path";
const LOG_FILE = join(
import.meta.dirname,
"mcp-server.log",
);
function formatMessage(
level: string,
message: string,
data?: unknown,
): string {
const timestamp = new Date().toISOString();
const dataStr = data
? `\n${JSON.stringify(data, null, 2)}`
: "";
return `[${timestamp}] [${level}] ${message}${dataStr}\n`;
}
export const logger = {
info(message: string, data?: unknown) {
const logMessage = formatMessage(
"INFO",
message,
data,
);
appendFileSync(LOG_FILE, logMessage);
},
error(message: string, error?: unknown) {
const logMessage = formatMessage(
"ERROR",
message,
error,
);
appendFileSync(LOG_FILE, logMessage);
},
// debug(message: string, data?: unknown) {
// const logMessage = formatMessage(
// "DEBUG",
// message,
// data,
// );
// appendFileSync(LOG_FILE, logMessage);
// },
};
```
--------------------------------------------------------------------------------
/website/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "website",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.7.0",
"@docusaurus/types": "3.7.0"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=18.0"
}
}
```
--------------------------------------------------------------------------------
/src/mcp/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@merill/lokka",
"version": "0.3.0",
"description": "Lokka is a Model Context Protocol (MCP) server for Microsoft Graph.",
"license": "MIT",
"author": "Merill",
"homepage": "https://lokka.dev",
"bugs": "https://github.com/merill/lokka/issues",
"main": "main.js",
"type": "module",
"keywords": [
"mcp",
"graph",
"microsoft",
"graph",
"model",
"context",
"protocol"
],
"repository": {
"type": "git",
"url": "git+https://github.com/merill/lokka.git",
"directory": "src/mcp"
},
"bin": {
"lokka": "build/main.js"
},
"scripts": {
"build": "tsc",
"build:unix": "tsc && chmod 755 build/main.js",
"demo:token": "node build/demo-token-auth.js",
"test:token": "node build/test-token-auth.js",
"test:simple": "node build/simple-token-test.js",
"test:live": "node build/live-test.js",
"start": "node build/main.js"
},
"files": [
"build"
],
"dependencies": {
"@azure/identity": "^4.3.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.7.0",
"@types/jsonwebtoken": "^9.0.10",
"isomorphic-fetch": "^3.0.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/isomorphic-fetch": "^0.0.39",
"@types/node": "^22.13.17",
"typescript": "^5.8.2"
}
}
```
--------------------------------------------------------------------------------
/website/docs/intro.md:
--------------------------------------------------------------------------------
```markdown
---
sidebar_position: 1
title: 🤖 Introduction
---
## What is Lokka?
Lokka is a simple yet powerful middleware that connects AI language models (like ChatGPT or Claude) to your Azure and Microsoft 365 tenant using the Azure and Microsoft Graph APIs.
This allows you to perform administrative tasks using natural language queries.
:::info
In technical terms, Lokka is an implementation of the [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) for the Microsoft Graph and Azure APIs.
:::
Here's a quick demo. Read on to learn how to set this up on your own machine.
<img src="https://github.com/merill/lokka/blob/main/assets/lokka-demo-1.gif?raw=true" alt="Lokka Demo - user create demo" width="500"/>
### Sample queries
Here are some examples of queries you can use with Lokka.
- `Create a new security group called 'Sales and HR' with a dynamic rule based on the department attribute.`
- `Find all the conditional access policies that haven't excluded the emergency access account`
- `Show me all the Intune device configuration policies assigned to the 'Call center' group`
- `What was the most expensive service in Azure last month?`
You can ask Lokka to do anything that Microsoft Graph can do which includes support for Entra, Intune, Teams and SharePoint. In addition to graph you can also work with your Azure resources as well.
:::note
The agent will only be able to perform the actions based on the Graph and Azure permissions you grant it.
:::
## What is MCP?
[Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is an open protocol that enables AI models to securely interact with local and remote resources through standardized server implementations.
Lokka is an implementation of the MCP protocol for the Microsoft Graph API.

## Getting started
Want to try Lokka? It's easy to get started!
Check out the [installation guide](./install).
```
--------------------------------------------------------------------------------
/website/docs/faq.md:
--------------------------------------------------------------------------------
```markdown
---
sidebar_position: 5
title: 👨💻 FAQs
---
## Who built this?
Lokka is a personal project by Merill Fernando a Product Manager at Microsoft. To learn more about me and my other projects, visit my website at [merill.net](https://merill.net).
I built this as a proof of concept to demonstrate the capabilities of using LLMs and MCPs for Microsoft 365 administration tasks.
This project is open source and available on [GitHub](https://github.com/merill/lokka).
## What is the difference between Lokka and Copilot?
Copilot is an enterprise grade AI solution from Microsoft and is natively integrated with Microsoft 365 while Lokka is an open source MCP server implementation for Microsoft Graph API.
Lokka is a simple middleware that allows you to use any compatible AI model and client.
This means you can experiment using paid offerings like Claude and Cursor or use open source models like Llama from Meta or Phi from Microsoft Research and run them completely offline on your own hardware.
:::note
Lokka is not a replacement for Copilot and is not affiliated with Microsoft.
:::
## Can I use this in production?
We recommend using Lokka in a test environment for exploration and testing purposes. The aim of this project is to provide a playground to experiment with using LLMs for Microsoft 365 administration tasks.
:::note
Lokka is not a production-ready solution and should not be used in a production environment. It is a proof of concept to demonstrate the capabilities of using LLMs for Microsoft 365 administration tasks.
:::
## Is this a Microsoft product?
No, Lokka is not a Microsoft product and is not affiliated with Microsoft.
## How do I report issues?
If you encounter any issues or have suggestions for improvements, please open an issue on the [GitHub repository](https://github.com/merill/lokka/issues).
## I'm seeing this error message, what should I do?
### TypeError `[ERR_INVALID_ARG_TYPE]`: The "path" argument must be of type string. Received undefined
Make sure you have the latest version of Node.js installed (v22.10.0 or higher). See [MCP Server issues](https://github.com/merill/lokka/issues/3) for other tips.
```
--------------------------------------------------------------------------------
/website/docs/install-advanced/token-auth.md:
--------------------------------------------------------------------------------
```markdown
---
title: 🔑 Token auth
sidebar_position: 4
slug: /install-advanced/token-auth
---
With token auth, the user provides a valid Microsoft Graph access token to the Lokka agent. This method is useful in dev scenarios where you want to use an existing token from the Azure CLI or another tool like Graph Explorer.
Configure the Lokka agent to use token auth by setting the `USE_CLIENT_TOKEN` environment variable to `true`.
```json
{
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"USE_CLIENT_TOKEN": "true"
}
}
}
```
When using client-provided token mode:
1. Start the MCP server with `USE_CLIENT_TOKEN=true`
2. Use the `set-access-token` tool to provide a valid Microsoft Graph access token (Press # in chat and type the `set` to see the tools that start with `set-`)
3. Use the `get-auth-status` tool to verify authentication status
4. Refresh tokens as needed using `set-access-token`
## Getting tokens
You can obtain a valid Microsoft Graph access token using the Azure CLI, Graph PowerShell or Graph Explorer.
This method is useful for development and testing purposes, but it is not recommended for production use due to security concerns.
In addition, access token are short-lived (typically 1 hour) and will need to be refreshed periodically.
### Option 1: Graph Explorer
1. Go to [Graph Explorer](https://aka.ms/ge)
2. Sign in with your Microsoft account
3. Select the **Access token** tab in the top pane below the URL bar
#### To add additional permissions to the token
1. Click on the **Modify permissions** button
2. Search for the permissions you want to add (e.g. `User.Read.All`)
3. Click **Add permissions**
4. Click **Consent on behalf of your organization** to grant admin consent for the permissions
5. Copy the access token from the **Access token** tab
### Option 2: Azure CLI
```bash
# Login to Azure CLI
az login
# Get a token for Microsoft Graph
az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv
```
### Option 3: Graph PowerShell
```powershell
# Login to Graph PowerShell
Connect-MgGraph
# Get a token for Microsoft Graph
$data = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/me" -Method GET -OutputType HttpResponseMessage
$data.RequestMessage.Headers.Authorization.Parameter
```
```
--------------------------------------------------------------------------------
/website/docs/install-advanced/interactive-auth.md:
--------------------------------------------------------------------------------
```markdown
---
title: 👤 Interactive auth
sidebar_position: 2
slug: /install-advanced/interactive-auth
---
This authentication method opens a browser window and prompts the user to sign into their Microsoft tenant.
It currently requires the user to authenticate each time the client application (Claude, VS Code) is started.
Interactive auth also allows the client to dynamically request and consent to additional permissions without having to look up the app in the Entra portal and grant permissions.
## Option 1: Interactive auth with default app
This method is outlined in the quick start [Install Guide](/docs/install)
## Option 2: Interactive auth with custom app
If you wish to use a custom Microsoft Entra app, you can create a new app registration in your Microsoft Entra tenant.
### Create an Entra app for App-Only auth with Lokka
- Open [Entra admin center](https://entra.microsoft.com) > **Identity** > **Applications** > **App registrations**
- Tip: [enappreg.cmd.ms](https://enappreg.cmd.ms) is a shortcut to the App registrations page.
- Select **New registration**
- Enter a name for the application (e.g. `Lokka`)
- Leave the **Supported account types** as `Accounts in this organizational directory only (Single tenant)`.
- In the **Redirect URI** section, select `Public client/native (mobile & desktop)` and enter `http://localhost`.
- Select **Register**
- Select **API permissions** > **Add a permission**
- Select **Microsoft Graph** > **Delegate permissions**
- Search for each of the permissions and check the box next to each permission you want to allow.
- Start with at least `User.Read.All` to be able to query users in your tenant (you can add more permissions later).
- The agent will only be able to perform the actions based on the permissions you grant it.
- Select **Add permissions**
- Select **Grant admin consent for [your organization]**
- Select **Yes** to confirm
In Claude desktop or VS Code you will need to provide the tenant ID and client ID of the application you just created.
The `USE_INTERACTIVE` needs to be set to `true` when using a custom app for interactive auth.
```json
{
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"USE_INTERACTIVE": "true"
}
}
}
```
```
--------------------------------------------------------------------------------
/website/docs/install-advanced/app-only-auth.md:
--------------------------------------------------------------------------------
```markdown
---
title: 📦 App-only auth
sidebar_position: 3
slug: /install-advanced/app-only-auth
---
This authentication method uses the client credentials flow to authenticate the agent with Microsoft Graph API.
You can use either certificate (recommended) or client secret authentication with the following configuration. In both instances, you need to create a Microsoft Entra application and grant it the necessary permissions.
## Create an Entra app for App-Only auth with Lokka
- Open [Entra admin center](https://entra.microsoft.com) > **Identity** > **Applications** > **App registrations**
- Tip: [enappreg.cmd.ms](https://enappreg.cmd.ms) is a shortcut to the App registrations page.
- Select **New registration**
- Enter a name for the application (e.g. `Lokka`)
- Select **Register**
- Select **API permissions** > **Add a permission**
- Select **Microsoft Graph** > **Application permissions**
- Search for each of the permissions and check the box next to each permission you want to allow.
- The agent will only be able to perform the actions based on the permissions you grant it.
- Select **Add permissions**
- Select **Grant admin consent for [your organization]**
- Select **Yes** to confirm
## Option 1: App-Only Auth with Certificate (recommended for app-only auth)
Once the app is created and you've added a certificate you can configure the cert's location as shown below.
```json
{
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CERTIFICATE_PATH": "/path/to/certificate.pem",
"CERTIFICATE_PASSWORD": "<optional-certificate-password>",
"USE_CERTIFICATE": "true"
}
}
}
```
Tip: Use the command below to convert a PFX client certificate to a PEM-encoded certificate.
```bash
openssl pkcs12 -in /path/to/cert.pfx -out /path/to/cert.pem -nodes -clcerts
```
## Option 2: App-Only Auth with Client Secret
### Create a client secret
- In the Entra protal navigate to the app you created earlier
- Select **Certificates & secrets** > **Client secrets** > **New client secret**
- Enter a description for the secret (e.g. `Agent Config`)
- Select **Add**
- Copy the value of the secret, we will use this value in the agent configuration file.
You can now configure Lokka in VSCode, Claude using the config below.
```json
{
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
```
```
--------------------------------------------------------------------------------
/website/blog/2019-05-29-long-blog-post.md:
--------------------------------------------------------------------------------
```markdown
---
slug: long-blog-post
title: Long Blog Post
authors: yangshun
tags: [hello, docusaurus]
---
This is the summary of a very long blog post,
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
<!-- truncate -->
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
```
--------------------------------------------------------------------------------
/website/src/css/custom.css:
--------------------------------------------------------------------------------
```css
/**
* Any CSS included here will be global. The classic template
* bundles Infima by default. Infima is a CSS framework designed to
* work well for content-centric websites.
*/
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #2e8555;
--ifm-color-primary-dark: #29784c;
--ifm-color-primary-darker: #277148;
--ifm-color-primary-darkest: #205d3b;
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
--ifm-color-primary: #25c2a0;
--ifm-color-primary-dark: #21af90;
--ifm-color-primary-darker: #1fa588;
--ifm-color-primary-darkest: #1a8870;
--ifm-color-primary-light: #29d5b0;
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
.navbar--github-link {
width: 32px;
height: 32px;
padding: 6px;
margin-right: 20px;
margin-left: 6px;
border-radius: 50%;
transition: background var(--ifm-transition-fast);
}
.navbar--github-link:hover {
background: var(--ifm-color-emphasis-200);
}
.navbar--github-link:before {
content: "";
height: 100%;
display: block;
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
no-repeat;
}
html[data-theme="dark"] .navbar--github-link:before {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
no-repeat;
}
```
--------------------------------------------------------------------------------
/website/src/pages/styles.module.css:
--------------------------------------------------------------------------------
```css
.hero {
padding: 4rem 0 6rem;
text-align: center;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
align-items: center;
}
.heroBanner {
display: flex;
flex-direction: row;
align-items: center;
gap: 2rem;
}
.heroContent {
flex: 1;
margin-bottom: 2.5rem;
max-width: 800px;
}
.heroTitle {
font-size: 4rem;
margin-bottom: 1.5rem;
font-weight: 800;
letter-spacing: -0.05em;
line-height: 1.1;
}
.heroSubtitle {
font-size: 1.25rem;
margin-bottom: 2rem;
opacity: 0.8;
font-weight: 400;
line-height: 1.6;
letter-spacing: -0.015em;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.videoContainer {
flex: 1;
width: 80%;
max-width: 900px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
position: relative;
margin: 0 auto 3rem;
transition: transform 0.3s ease;
}
.thumbnailContainer {
position: relative;
cursor: pointer;
width: 100%;
transform-style: preserve-3d;
transform: perspective(1000px);
will-change: transform;
transition: transform 0.3s ease;
}
.thumbnailContainer:hover {
transform: none;
}
.thumbnail {
width: 100%;
height: auto;
display: block;
border-radius: 16px;
}
.videoFrame {
width: 100%;
height: 500px;
display: block;
border-radius: 16px;
}
.playButtonContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.playButtonOuter {
height: 90px;
width: 90px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.playButtonInner {
height: 70px;
width: 70px;
background: black;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.playIcon {
width: 0;
height: 0;
border-style: solid;
border-width: 12px 0 12px 24px;
border-color: transparent transparent transparent white;
margin-left: 5px;
}
.thumbnailContainer:hover .playButtonOuter {
transform: scale(1.1);
}
.buttonContainer {
margin-top: 1rem;
display: flex;
justify-content: center;
}
.tryButton {
display: inline-block;
background-color: #000;
color: #fff;
font-size: 1.1rem;
font-weight: 600;
padding: 14px 32px;
border-radius: 50px;
text-decoration: none;
transition: all 0.2s ease;
border: 2px solid #000;
}
.tryButton:hover {
background-color: #fff;
color: #000;
text-decoration: none;
}
/* Dark theme styles */
html[data-theme='dark'] .tryButton {
background-color: #fff;
color: #000;
border: 2px solid #fff;
}
html[data-theme='dark'] .tryButton:hover {
background-color: transparent;
color: #fff;
border: 2px solid #fff;
}
@media screen and (max-width: 996px) {
.heroBanner {
flex-direction: column;
text-align: center;
}
.heroTitle {
font-size: 3rem;
}
.heroSubtitle {
font-size: 1.1rem;
}
.videoFrame {
height: 300px;
}
.videoContainer {
width: 95%;
}
.playButtonOuter {
height: 70px;
width: 70px;
}
.playButtonInner {
height: 50px;
width: 50px;
}
.playIcon {
border-width: 10px 0 10px 18px;
}
}
```
--------------------------------------------------------------------------------
/website/src/pages/index.js:
--------------------------------------------------------------------------------
```javascript
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import React, { useState, useRef, useEffect } from "react";
import Link from '@docusaurus/Link';
import styles from "./styles.module.css";
function VideoPlayer() {
const [showVideo, setShowVideo] = useState(false);
const thumbnailRef = useRef(null);
const [tiltStyle, setTiltStyle] = useState({});
const playVideo = () => {
setShowVideo(true);
};
useEffect(() => {
const container = thumbnailRef.current;
if (!container) return;
const handleMouseMove = (e) => {
if (showVideo) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left; // x position within the element
const y = e.clientY - rect.top; // y position within the element
// Calculate the tilt angle based on mouse position
// The further from center, the more tilt (up to max degrees)
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const maxTiltDegrees = 5; // Maximum tilt in degrees
const tiltX = ((y - centerY) / centerY) * -maxTiltDegrees;
const tiltY = ((x - centerX) / centerX) * maxTiltDegrees;
setTiltStyle({
transform: `perspective(1000px) rotateX(${tiltX}deg) rotateY(${tiltY}deg)`,
transition: 'transform 0.05s ease-out'
});
};
const handleMouseLeave = () => {
setTiltStyle({
transform: 'perspective(1000px) rotateX(0deg) rotateY(0deg)',
transition: 'transform 0.5s ease-out'
});
};
container.addEventListener('mousemove', handleMouseMove);
container.addEventListener('mouseleave', handleMouseLeave);
return () => {
container.removeEventListener('mousemove', handleMouseMove);
container.removeEventListener('mouseleave', handleMouseLeave);
};
}, [showVideo]);
return (
<div className={styles.videoContainer}>
{!showVideo ? (
<div
ref={thumbnailRef}
className={styles.thumbnailContainer}
onClick={playVideo}
style={tiltStyle}
>
<img
className={styles.thumbnail}
src="/img/lokka-intro-video.png"
alt="Lokka Demo - Introducing Lokka"
/>
<div className={styles.playButtonContainer}>
<div className={styles.playButtonOuter}>
<div className={styles.playButtonInner}>
<div className={styles.playIcon}></div>
</div>
</div>
</div>
</div>
) : (
<iframe
className={styles.videoFrame}
src="https://www.youtube.com/embed/f-ECqQSpLCM?autoplay=1"
title="Lokka Demo - Introducing Lokka"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
)}
</div>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title="Lokka"
description="Lokka is an AI agent tool that brings the power of Microsoft Graph to AI agents like GitHub Copilot and Claude that run on your local desktop.">
<main>
<div className={styles.hero}>
<div className={styles.container}>
<div className={styles.heroContent}>
<h1 className={styles.heroTitle}>Lokka</h1>
<p className={styles.heroSubtitle}>Lokka is an AI agent tool that brings the power of Microsoft Graph to AI agents like GitHub Copilot and Claude. The best part is you can get started for free and it runs on your desktop.</p>
<p className={styles.heroSubtitle}>Get a glimpse into the future of administering Microsoft 365 👇</p>
</div>
<VideoPlayer />
<div className={styles.buttonContainer}>
<Link
className={styles.tryButton}
to="/docs/install">
Try Lokka
</Link>
</div>
</div>
</div>
</main>
</Layout>
);
}
```
--------------------------------------------------------------------------------
/website/docusaurus.config.js:
--------------------------------------------------------------------------------
```javascript
// @ts-check
// `@type` JSDoc annotations allow editor autocompletion and type checking
// (when paired with `@ts-check`).
// There are various equivalent ways to declare your Docusaurus config.
// See: https://docusaurus.io/docs/api/docusaurus-config
import { themes as prismThemes } from "prism-react-renderer";
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Lokka",
tagline:
"Beyond Commands, Beyond Clicks. A glimpse into the future of managing Microsoft 365 with AI!",
favicon: "img/favicon.ico",
// Set the production url of your site here
url: "https://lokka.dev",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "merill", // Usually your GitHub org/user name.
projectName: "lokka", // Usually your repo name.
onBrokenLinks: "ignore",
onBrokenMarkdownLinks: "warn",
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"],
},
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: "./sidebars.js",
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/merill/lokka/tree/main/website/",
},
blog: {
showReadingTime: true,
feedOptions: {
type: ["rss", "atom"],
xslt: true,
},
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/merill/lokka/tree/main/",
// Useful options to enforce blogging best practices
onInlineTags: "warn",
onInlineAuthors: "warn",
onUntruncatedBlogPosts: "warn",
},
theme: {
customCss: "./src/css/custom.css",
},
}),
],
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
// Replace with your project's social card
image: "img/docusaurus-social-card.png",
navbar: {
title: "Lokka",
logo: {
alt: "Lokka logo",
src: "img/logo.svg",
},
items: [
{
type: "docSidebar",
sidebarId: "siteSidebar",
position: "left",
label: "Docs",
},
{
href: "https://merill.net",
label: "merill.net",
position: "right",
},
{
"aria-label": "GitHub Repository",
className: "navbar--github-link",
href: "https://github.com/merill/lokka",
position: "right",
},
],
},
footer: {
style: "dark",
links: [
{
title: "My M365 tools",
items: [
{
href: "https://graphxray.merill.net",
label: "Graph X-Ray",
position: "right",
},
{
href: "https://graphpermissions.merill.net",
label: "Graph Permissions Explorer",
position: "right",
},
{
href: "https://maester.dev",
label: "Maester",
position: "right",
},
],
},
{
title: "My other tools",
items: [
{
href: "https://cmd.ms",
label: "cmd.ms",
position: "right",
},
{
href: "https://akasearch.net",
label: "akasearch.net",
position: "right",
},
{
href: "https://mc.merill.net",
label: "Message Center Archive",
position: "right",
},
],
},
{
title: "Follow Me",
items: [
{
label: "LinkedIn",
href: "https://linkedin.com/in/merill",
},
{
label: "Bluesky",
href: "https://bsky.app/profile/merill.net",
},
{
label: "X",
href: "https://x.com/merill",
},
],
},
{
title: "My Entra specials",
items: [
{
label: "Entra.News - My weekly newsletter",
href: "https://entra.news",
},
{
label: "Entra.Chat - My weekly podcast",
href: "https://entra.chat",
},
{
label: "idPowerToys",
href: "https://idpowerapp.com",
},
],
},
],
copyright: `Copyright © ${new Date().getFullYear()} Merill Fernando.`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
},
}),
};
export default config;
```
--------------------------------------------------------------------------------
/website/docs/developer-guide.md:
--------------------------------------------------------------------------------
```markdown
---
title: 🧩 Developer guide
sidebar_position: 4
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Follow this guide if you want to build Lokka from source to contribute to the project.
## Pre-requisites
- Follow the [installation guide](install) to install Node and the [advanced guide](install-advanced) if you wish to create a custom Entra application.
- Clone the Lokka repository from GitHub [https://github.com/merill/lokka](https://github.com/merill/lokka)
## Building the project
- Open a terminal and navigate to the Lokka project directory.
- Change into the folder `\src\mcp\`
- Run the following command to install the dependencies:
```bash
npm install
```
- After the dependencies are installed, run the following command to build the project:
```bash
npm run build
```
- When the build is complete, you will see a main.js file find the compiled files in the `\src\mcp\build\` folder.
## Configuring the agent
<Tabs>
<TabItem value="claude" label="Claude" default>
- In Claude Desktop, open the settings by clicking on the hamburger icon in the top left corner.
- Select **File** > **Settings** (or press `Ctrl + ,`)
- In the **Developer** tab, click **Edit Config**
- This opens explorer, edit `claude_desktop_config.json` in your favorite text editor.
- Add the following configuration to the file, using the information you in the **Overview** blade of the Entra application you created earlier.
- Note: On Windows the path needs to be escaped with `\\` or use `/` instead of `\`.
- E.g. `C:\\Users\\<username>\\Documents\\lokka\\src\\mcp\\build\\main.js` or `C:/Users/<username>/Documents/lokka/src/mcp/build/main.js`
- Tip: Right-click on `build\main.js` in VS Code and select `Copy path` to copy the full path.
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "node",
"args": [
"<absolute-path-to-main.js>/src/mcp/build/main.js"
],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
}
```
- Exit Claude Desktop and restart it.
- Every time you make changes to the code or configuration, you need to restart Claude desktop for the changes to take effect.
- Note: In Windows, Claude doesn't exit when you close the window, it runs in the background. You can find it in the system tray. Right-click on the icon and select **Quit** to exit the application completely.
### Testing the agent
#### Testing with Claude Desktop
- Open the Claude Desktop application.
- In the chat window on the bottom right you should see a hammer icon if the configuration is correct.
- Now you can start quering your Microsoft tenant using the Lokka agent tool.
- Some sample queries you can try are:
- `Get all the users in my tenant`
- `Show me the details for John Doe`
- `Change John's department to IT` - Needs User.ReadWrite.All permission to be granted
</TabItem>
<TabItem value="vscode" label="VS Code">
### Pre-requisites
- Install the latest version of [VS Code - Insider](https://code.visualstudio.com/insiders/)
- Install the latest version of [GitHub Copilot in VS Code](https://code.visualstudio.com/docs/copilot/setup)
### VS Code
- In VS Code, open the Command Palette by pressing `Ctrl + Shift +P` (or `Cmd + Shift + P` on Mac).
- Type `MCP` and select `Command (stdio)`
- Select
- Command: `node`
- Server ID: `Lokka-Microsoft`
- Where to save configuration: `User Settings`
- This will open the `settings.json` file in VS Code.
- Add the following configuration to the file, using the information you in the **Overview** blade of the Entra application you created earlier.
- Note: On Windows the path needs to be escaped with `\\` or use `/` instead of `\`.
- E.g. `C:\\Users\\<username>\\Documents\\lokka\\src\\mcp\\build\\main.js` or `C:/Users/<username>/Documents/lokka/src/mcp/build/main.js`
- Tip: Right-click on `build\main.js` in VS Code and select `Copy path` to copy the full path.
```json
"mcp": {
"servers": {
"Lokka-Microsoft": {
"command": "node",
"args": [
"<absolute-path-to-main.js>/src/mcp/build/main.js"
],
"env": {
"TENANT_ID": "<tenant-id>",
"CLIENT_ID": "<client-id>",
"CLIENT_SECRET": "<client-secret>"
}
}
}
}
```
- `File` > `Save` to save the file.
### Testing the agent
- Start a new instance of VS Code (File > New Window)
- Open `Copilot Edits` from `View` → `Copilot Edits`
- At the bottom of the Copilot Edits panel (below the chat box)
- Select `Agent` (if it is showing `Edit`)
- Select `Claude 3.7 Sonnet` (if it is showing `GPT-40`)
</TabItem>
</Tabs>
#### Testing with MCP Inspector
MCP Inspector is a tool that allows you to test and debug your MCP server directly (without an LLM). It provides a user interface to send requests to the server and view the responses.
See the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for more information.
```console
npx @modelcontextprotocol/inspector node path/to/server/main.js args...
```
## Learn about MCP
- [Model Context Protocol Tutorial by Matt Pocock](https://www.aihero.dev/model-context-protocol-tutorial) - This is a great tutorial that explains the Model Context Protocol and how to use it.
- [Model Context Protocol docs](https://modelcontextprotocol.io/introduction) - The official docs for the Model Context Protocol.
- [Model Context Protocol Clients](https://modelcontextprotocol.io/clients) - List of all the clients that support the Model Context Protocol.
```
--------------------------------------------------------------------------------
/website/docs/install.mdx:
--------------------------------------------------------------------------------
```markdown
---
title: 🚀 Install
sidebar_position: 2
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Install Lokka
This quick start guide will help you set up Lokka with the minimum configuration needed to get started.
If you want to learn more about the advanced configuration options, see the [Advanced Install Guide](/docs/install-advanced).
## Pre-requisites
- Install [Node.js](https://nodejs.org/en/download/)
- If you already have Node v22.10 or higher installed you can skip this step.
- Check by running `node -v` at the command prompt.
## Configure the agent
You can use the Lokka agent tool with any compatible MCP client and LLM combo.
Claude is the simplest way to get started and provides the best experience. You can use the free version of Claude Desktop to test Lokka (there are daily limits on the free version).
GitHub Copilot Agent in VS Code is another great option.
<Tabs>
<TabItem value="claude" label="Claude" default>
### Install Claude Desktop
- Download the latest version of Claude Desktop from [https://claude.ai/download](https://claude.ai/download)
- Install the application by following the instructions on the website.
- Open the application and sign in with your account (you can register for a free account).
### Add Lokka to Claude Desktop
- In Claude Desktop, open Settings by clicking on the hamburger icon in the top left corner.
- Select **File** > **Settings** (or press `Ctrl + ,`)
- On Mac, you can find the settings in the top menu bar under **Claude** > **Settings** (or press `Cmd + ,`).
- In the **Developer** tab, click **Edit Config**
- Note: If you don't see the Developer tab, you need to enable it first from `Help` > `Enable Developer Mode`.
- This opens explorer, edit `claude_desktop_config.json` in your favorite text editor.
- Add the following configuration to the file.
```json
{
"mcpServers": {
"Lokka-Microsoft": {
"command": "npx",
"args": ["-y", "@merill/lokka"]
}
}
}
```
- Exit Claude Desktop and restart it.
- Every time you make changes to the code or configuration, you need to restart Claude desktop for the changes to take effect.
- Note: In Windows, Claude doesn't exit when you close the window, it runs in the background. You can find it in the system tray. Right-click on the icon and select **Quit** to exit the application completely.
### Testing the agent
- Open the Claude Desktop application.
- You should see new browser window open and prompt you to sign into your Microsoft tenant.
- Now you can start quering your Microsoft tenant using the Lokka agent tool.
- Some sample queries you can try are:
- `Get all users in my tenant`
- `Show me the details for John Doe`
- `Change John's department to IT` - Needs User.ReadWrite.All permission to be granted
- `How many VMs do I have in my subscription?` - Needs Reader permission to be granted to the Azure subscription
</TabItem>
<TabItem value="vscode" label="VS Code">
### Pre-requisites
- Install the latest version of [VS Code](https://code.visualstudio.com)
### Add Lokka to GitHub Copilot
<Tabs>
<TabItem value="oneClickInstall" label="One Click Install" default>
* Start **VS Code** and then click the button below to install Lokka in VS Code.
* If your browser prompts you to open VS Code, click **Open**.
* In the VS Code **Lokka-Microsoft** install page
* Click **Install**.
* Click the widget icon next to the button and select **Start Server**.
* This will open a browser window and prompt you to sign into your Microsoft tenant.
| Platform | VS Code | VS Code Insiders |
| - | - | - |
| Windows | [](vscode:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22cmd%22%2C%22args%22%3A%5B%22%2Fc%22%2C%22npx%22%2C%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) | [](vscode-insiders:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22cmd%22%2C%22args%22%3A%5B%22%2Fc%22%2C%22npx%22%2C%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) |
| macOS/Linux | [](vscode:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) | [](vscode-insiders:mcp/install?%7B%22name%22%3A%22Lokka-Microsoft%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40merill%2Flokka%22%5D%7D) |
:::note
If VS Code was not running when you clicked the button, you might need to click the button on this page again to install Lokka.
:::
</TabItem>
<TabItem value="manualInstall" label="Manual Install" default>
- In VS Code, open the Command Palette by pressing `Ctrl + Shift +P` (or `Cmd + Shift + P` on Mac).
- Type `MCP: Add` and select `MCP: Add Server...`
- Select `Command (stdio)`
- Command:
- Windows: `cmd /c npx -y @merill/lokka`
- macOS/Linux: `npx -y @merill/lokka`
- Name: `Lokka-Microsoft`
- Where to install: `Global`
- This will open the `settings.json` file in VS Code.
- `File` > `Save` to save the file.
- Once you hit save, you should see a browser window open and prompt you to sign into your Microsoft tenant.
</TabItem>
</Tabs>
### Starting the MCP server manually
Typically VSCode will automatically start the MCP server when needed, but you can also stop and start the MCP server manually.
- Open the Command Palette again (`Ctrl + Shift +P`) and type `MCP` and select `MCP: List Servers`
- Select `Lokka-Microsoft` from the list of servers.
- Selet `Start Server`
- This will start the Lokka server
- Each time you hit Start you will see a browser window open and prompt you to sign into your Microsoft tenant.
- If you want to stay connected to the same tenant, you can use [AppApp-only authentication](/docs/install-advanced/app-only-auth).
### Testing the agent
- Start a new instance of VS Code (File > New Window)
- Open `Chat` from `View` → `Chat`
- At the bottom of the Chat panel (below the chat box)
- Select `Agent` (if it is showing `Ask` or `Edit`)
- Select `Claude Sonnet 4` or above (if it is showing `GPT-40`)
- Now you can start querying your Microsoft tenant using the Lokka agent tool.
- Some sample queries you can try are:
- `Get all users in my tenant`
- `Show me the details for John Doe`
- `Change John's department to IT` - Needs User.ReadWrite.All permission to be granted
- `How many VMs do I have in my subscription?` - Needs Reader permission to be granted to the Azure subscription
:::note
If the chat prompts you to install VS GitHub Copilot for Azure, click the **rerun without** link to continue using Lokka.
:::
</TabItem>
</Tabs>
```
--------------------------------------------------------------------------------
/src/mcp/src/auth.ts:
--------------------------------------------------------------------------------
```typescript
import { AccessToken, TokenCredential, ClientSecretCredential, ClientCertificateCredential, InteractiveBrowserCredential, DeviceCodeCredential, DeviceCodeInfo } from "@azure/identity";
import { AuthenticationProvider } from "@microsoft/microsoft-graph-client";
import jwt from "jsonwebtoken";
import { logger } from "./logger.js";
import { LokkaClientId, LokkaDefaultTenantId, LokkaDefaultRedirectUri } from "./constants.js";
// Constants
const ONE_HOUR_IN_MS = 60 * 60 * 1000; // One hour in milliseconds
// Helper function to parse JWT and extract scopes
function parseJwtScopes(token: string): string[] {
try {
// Decode JWT without verifying signature (we trust the token from Azure Identity)
const decoded = jwt.decode(token) as any;
if (!decoded || typeof decoded !== 'object') {
logger.info("Failed to decode JWT token");
return [];
}
// Extract scopes from the 'scp' claim (space-separated string)
const scopesString = decoded.scp;
if (typeof scopesString === 'string') {
return scopesString.split(' ').filter(scope => scope.length > 0);
}
// Some tokens might have roles instead of scopes
const roles = decoded.roles;
if (Array.isArray(roles)) {
return roles;
}
logger.info("No scopes found in JWT token");
return [];
} catch (error) {
logger.error("Error parsing JWT token for scopes", error);
return [];
}
}
// Simple authentication provider that works with Azure Identity TokenCredential
export class TokenCredentialAuthProvider implements AuthenticationProvider {
private credential: TokenCredential;
constructor(credential: TokenCredential) {
this.credential = credential;
}
async getAccessToken(): Promise<string> {
const token = await this.credential.getToken("https://graph.microsoft.com/.default");
if (!token) {
throw new Error("Failed to acquire access token");
}
return token.token;
}
}
export interface TokenBasedCredential extends TokenCredential {
getToken(scopes: string | string[]): Promise<AccessToken | null>;
}
export class ClientProvidedTokenCredential implements TokenBasedCredential {
private accessToken: string | undefined;
private expiresOn: Date | undefined;
constructor(accessToken?: string, expiresOn?: Date) {
if (accessToken) {
this.accessToken = accessToken;
this.expiresOn = expiresOn || new Date(Date.now() + ONE_HOUR_IN_MS); // Default 1 hour
} else {
this.expiresOn = new Date(0); // Set to epoch to indicate no valid token
}
}
async getToken(scopes: string | string[]): Promise<AccessToken | null> {
if (!this.accessToken || !this.expiresOn || this.expiresOn <= new Date()) {
logger.error("Access token is not available or has expired");
return null;
}
return {
token: this.accessToken,
expiresOnTimestamp: this.expiresOn.getTime()
};
} updateToken(accessToken: string, expiresOn?: Date): void {
this.accessToken = accessToken;
this.expiresOn = expiresOn || new Date(Date.now() + ONE_HOUR_IN_MS);
logger.info("Access token updated successfully");
}
isExpired(): boolean {
return !this.expiresOn || this.expiresOn <= new Date();
}
getExpirationTime(): Date {
return this.expiresOn || new Date(0);
}
// Getter for access token (for internal use by AuthManager)
getAccessToken(): string | undefined {
return this.accessToken;
}
}
export enum AuthMode {
ClientCredentials = "client_credentials",
ClientProvidedToken = "client_provided_token",
Interactive = "interactive",
Certificate = "certificate"
}
export interface AuthConfig {
mode: AuthMode;
tenantId?: string;
clientId?: string;
clientSecret?: string;
accessToken?: string;
expiresOn?: Date;
redirectUri?: string;
certificatePath?: string;
certificatePassword?: string;
}
export class AuthManager {
private credential: TokenCredential | null = null;
private config: AuthConfig;
constructor(config: AuthConfig) {
this.config = config;
}
async initialize(): Promise<void> {
switch (this.config.mode) {
case AuthMode.ClientCredentials:
if (!this.config.tenantId || !this.config.clientId || !this.config.clientSecret) {
throw new Error("Client credentials mode requires tenantId, clientId, and clientSecret");
}
logger.info("Initializing Client Credentials authentication");
this.credential = new ClientSecretCredential(
this.config.tenantId,
this.config.clientId,
this.config.clientSecret
);
break;
case AuthMode.ClientProvidedToken:
logger.info("Initializing Client Provided Token authentication");
this.credential = new ClientProvidedTokenCredential(
this.config.accessToken,
this.config.expiresOn
);
break;
case AuthMode.Certificate:
if (!this.config.tenantId || !this.config.clientId || !this.config.certificatePath) {
throw new Error("Certificate mode requires tenantId, clientId, and certificatePath");
}
logger.info("Initializing Certificate authentication");
this.credential = new ClientCertificateCredential(this.config.tenantId, this.config.clientId, {
certificatePath: this.config.certificatePath,
certificatePassword: this.config.certificatePassword
});
break;
case AuthMode.Interactive:
// Use defaults if not provided
const tenantId = this.config.tenantId || LokkaDefaultTenantId;
const clientId = this.config.clientId || LokkaClientId;
logger.info(`Initializing Interactive authentication with tenant ID: ${tenantId}, client ID: ${clientId}`);
try {
// Try Interactive Browser first
this.credential = new InteractiveBrowserCredential({
tenantId: tenantId,
clientId: clientId,
redirectUri: this.config.redirectUri || LokkaDefaultRedirectUri,
});
} catch (error) {
// Fallback to Device Code flow
logger.info("Interactive browser failed, falling back to device code flow");
this.credential = new DeviceCodeCredential({
tenantId: tenantId,
clientId: clientId,
userPromptCallback: (info: DeviceCodeInfo) => {
console.log(`\n🔐 Authentication Required:`);
console.log(`Please visit: ${info.verificationUri}`);
console.log(`And enter code: ${info.userCode}\n`);
return Promise.resolve();
},
});
}
break;
default:
throw new Error(`Unsupported authentication mode: ${this.config.mode}`);
}
// Test the credential
await this.testCredential();
}
updateAccessToken(accessToken: string, expiresOn?: Date): void {
if (this.config.mode === AuthMode.ClientProvidedToken && this.credential instanceof ClientProvidedTokenCredential) {
this.credential.updateToken(accessToken, expiresOn);
} else {
throw new Error("Token update only supported in client provided token mode");
}
}
private async testCredential(): Promise<void> {
if (!this.credential) {
throw new Error("Credential not initialized");
}
// Skip testing if ClientProvidedToken mode has no initial token
if (this.config.mode === AuthMode.ClientProvidedToken && !this.config.accessToken) {
logger.info("Skipping initial credential test as no token was provided at startup.");
return;
}
try {
const token = await this.credential.getToken("https://graph.microsoft.com/.default");
if (!token) {
throw new Error("Failed to acquire token");
}
logger.info("Authentication successful");
} catch (error) {
logger.error("Authentication test failed", error);
throw error;
}
}
getGraphAuthProvider(): TokenCredentialAuthProvider {
if (!this.credential) {
throw new Error("Authentication not initialized");
}
return new TokenCredentialAuthProvider(this.credential);
}
getAzureCredential(): TokenCredential {
if (!this.credential) {
throw new Error("Authentication not initialized");
}
return this.credential;
}
getAuthMode(): AuthMode {
return this.config.mode;
}
isClientCredentials(): boolean {
return this.config.mode === AuthMode.ClientCredentials;
}
isClientProvidedToken(): boolean {
return this.config.mode === AuthMode.ClientProvidedToken;
}
isInteractive(): boolean {
return this.config.mode === AuthMode.Interactive;
}
async getTokenStatus(): Promise<{ isExpired: boolean; expiresOn?: Date; scopes?: string[] }> {
if (this.credential instanceof ClientProvidedTokenCredential) {
const tokenStatus = {
isExpired: this.credential.isExpired(),
expiresOn: this.credential.getExpirationTime()
};
// If we have a valid token, parse it to extract scopes
if (!tokenStatus.isExpired) {
const accessToken = this.credential.getAccessToken();
if (accessToken) {
try {
const scopes = parseJwtScopes(accessToken);
return {
...tokenStatus,
scopes: scopes
};
} catch (error) {
logger.error("Error parsing token scopes in getTokenStatus", error);
return tokenStatus;
}
}
}
return tokenStatus;
} else if (this.credential) {
// For other credential types, try to get a fresh token and parse it
try {
const accessToken = await this.credential.getToken("https://graph.microsoft.com/.default");
if (accessToken && accessToken.token) {
const scopes = parseJwtScopes(accessToken.token);
return {
isExpired: false,
expiresOn: new Date(accessToken.expiresOnTimestamp),
scopes: scopes
};
}
} catch (error) {
logger.error("Error getting token for scope parsing", error);
}
}
return { isExpired: false };
}
}
```
--------------------------------------------------------------------------------
/src/mcp/src/main.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Client, PageIterator, PageCollection } from "@microsoft/microsoft-graph-client";
import fetch from 'isomorphic-fetch'; // Required polyfill for Graph client
import { logger } from "./logger.js";
import { AuthManager, AuthConfig, AuthMode } from "./auth.js";
import { LokkaClientId, LokkaDefaultTenantId, LokkaDefaultRedirectUri, getDefaultGraphApiVersion } from "./constants.js";
// Set up global fetch for the Microsoft Graph client
(global as any).fetch = fetch;
// Create server instance
const server = new McpServer({
name: "Lokka-Microsoft",
version: "0.2.0", // Updated version for token-based auth support
});
logger.info("Starting Lokka Multi-Microsoft API MCP Server (v0.2.0 - Token-Based Auth Support)");
// Initialize authentication and clients
let authManager: AuthManager | null = null;
let graphClient: Client | null = null;
// Check USE_GRAPH_BETA environment variable
const useGraphBeta = process.env.USE_GRAPH_BETA !== 'false'; // Default to true unless explicitly set to 'false'
const defaultGraphApiVersion = getDefaultGraphApiVersion();
logger.info(`Graph API default version: ${defaultGraphApiVersion} (USE_GRAPH_BETA=${process.env.USE_GRAPH_BETA || 'undefined'})`);
server.tool(
"Lokka-Microsoft",
"A versatile tool to interact with Microsoft APIs including Microsoft Graph (Entra) and Azure Resource Management. IMPORTANT: For Graph API GET requests using advanced query parameters ($filter, $count, $search, $orderby), you are ADVISED to set 'consistencyLevel: \"eventual\"'.",
{
apiType: z.enum(["graph", "azure"]).describe("Type of Microsoft API to query. Options: 'graph' for Microsoft Graph (Entra) or 'azure' for Azure Resource Management."),
path: z.string().describe("The Azure or Graph API URL path to call (e.g. '/users', '/groups', '/subscriptions')"),
method: z.enum(["get", "post", "put", "patch", "delete"]).describe("HTTP method to use"),
apiVersion: z.string().optional().describe("Azure Resource Management API version (required for apiType Azure)"),
subscriptionId: z.string().optional().describe("Azure Subscription ID (for Azure Resource Management)."),
queryParams: z.record(z.string()).optional().describe("Query parameters for the request"),
body: z.record(z.string(), z.any()).optional().describe("The request body (for POST, PUT, PATCH)"),
graphApiVersion: z.enum(["v1.0", "beta"]).optional().default(defaultGraphApiVersion as "v1.0" | "beta").describe(`Microsoft Graph API version to use (default: ${defaultGraphApiVersion})`),
fetchAll: z.boolean().optional().default(false).describe("Set to true to automatically fetch all pages for list results (e.g., users, groups). Default is false."),
consistencyLevel: z.string().optional().describe("Graph API ConsistencyLevel header. ADVISED to be set to 'eventual' for Graph GET requests using advanced query parameters ($filter, $count, $search, $orderby)."),
},
async ({
apiType,
path,
method,
apiVersion,
subscriptionId,
queryParams,
body,
graphApiVersion,
fetchAll,
consistencyLevel
}: {
apiType: "graph" | "azure";
path: string;
method: "get" | "post" | "put" | "patch" | "delete";
apiVersion?: string;
subscriptionId?: string;
queryParams?: Record<string, string>;
body?: any;
graphApiVersion: "v1.0" | "beta";
fetchAll: boolean;
consistencyLevel?: string;
}) => {
// Override graphApiVersion if USE_GRAPH_BETA is explicitly set to false
const effectiveGraphApiVersion = !useGraphBeta ? "v1.0" : graphApiVersion;
logger.info(`Executing Lokka-Microsoft tool with params: apiType=${apiType}, path=${path}, method=${method}, graphApiVersion=${effectiveGraphApiVersion}, fetchAll=${fetchAll}, consistencyLevel=${consistencyLevel}`);
let determinedUrl: string | undefined;
try {
let responseData: any;
// --- Microsoft Graph Logic ---
if (apiType === 'graph') {
if (!graphClient) {
throw new Error("Graph client not initialized");
}
determinedUrl = `https://graph.microsoft.com/${effectiveGraphApiVersion}`; // For error reporting
// Construct the request using the Graph SDK client
let request = graphClient.api(path).version(effectiveGraphApiVersion);
// Add query parameters if provided and not empty
if (queryParams && Object.keys(queryParams).length > 0) {
request = request.query(queryParams);
}
// Add ConsistencyLevel header if provided
if (consistencyLevel) {
request = request.header('ConsistencyLevel', consistencyLevel);
logger.info(`Added ConsistencyLevel header: ${consistencyLevel}`);
}
// Handle different methods
switch (method.toLowerCase()) {
case 'get':
if (fetchAll) {
logger.info(`Fetching all pages for Graph path: ${path}`);
// Fetch the first page to get context and initial data
const firstPageResponse: PageCollection = await request.get();
const odataContext = firstPageResponse['@odata.context']; // Capture context from first page
let allItems: any[] = firstPageResponse.value || []; // Initialize with first page's items
// Callback function to process subsequent pages
const callback = (item: any) => {
allItems.push(item);
return true; // Return true to continue iteration
};
// Create a PageIterator starting from the first response
const pageIterator = new PageIterator(graphClient, firstPageResponse, callback);
// Iterate over all remaining pages
await pageIterator.iterate();
// Construct final response with context and combined values under 'value' key
responseData = {
'@odata.context': odataContext,
value: allItems
};
logger.info(`Finished fetching all Graph pages. Total items: ${allItems.length}`);
} else {
logger.info(`Fetching single page for Graph path: ${path}`);
responseData = await request.get();
}
break;
case 'post':
responseData = await request.post(body ?? {});
break;
case 'put':
responseData = await request.put(body ?? {});
break;
case 'patch':
responseData = await request.patch(body ?? {});
break;
case 'delete':
responseData = await request.delete(); // Delete often returns no body or 204
// Handle potential 204 No Content response
if (responseData === undefined || responseData === null) {
responseData = { status: "Success (No Content)" };
}
break;
default:
throw new Error(`Unsupported method: ${method}`);
}
} // --- Azure Resource Management Logic (using direct fetch) ---
else { // apiType === 'azure'
if (!authManager) {
throw new Error("Auth manager not initialized");
}
determinedUrl = "https://management.azure.com"; // For error reporting
// Acquire token for Azure RM
const azureCredential = authManager.getAzureCredential();
const tokenResponse = await azureCredential.getToken("https://management.azure.com/.default");
if (!tokenResponse || !tokenResponse.token) {
throw new Error("Failed to acquire Azure access token");
}
// Construct the URL (similar to previous implementation)
let url = determinedUrl;
if (subscriptionId) {
url += `/subscriptions/${subscriptionId}`;
}
url += path;
if (!apiVersion) {
throw new Error("API version is required for Azure Resource Management queries");
}
const urlParams = new URLSearchParams({ 'api-version': apiVersion });
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
urlParams.append(String(key), String(value));
}
}
url += `?${urlParams.toString()}`;
// Prepare request options
const headers: Record<string, string> = {
'Authorization': `Bearer ${tokenResponse.token}`,
'Content-Type': 'application/json'
};
const requestOptions: RequestInit = {
method: method.toUpperCase(),
headers: headers
};
if (["POST", "PUT", "PATCH"].includes(method.toUpperCase())) {
requestOptions.body = body ? JSON.stringify(body) : JSON.stringify({});
}
// --- Pagination Logic for Azure RM (Manual Fetch) ---
if (fetchAll && method === 'get') {
logger.info(`Fetching all pages for Azure RM starting from: ${url}`);
let allValues: any[] = [];
let currentUrl: string | null = url;
while (currentUrl) { logger.info(`Fetching Azure RM page: ${currentUrl}`);
// Re-acquire token for each page (Azure tokens might expire)
const azureCredential = authManager.getAzureCredential();
const currentPageTokenResponse = await azureCredential.getToken("https://management.azure.com/.default");
if (!currentPageTokenResponse || !currentPageTokenResponse.token) {
throw new Error("Failed to acquire Azure access token during pagination");
}
const currentPageHeaders = { ...headers, 'Authorization': `Bearer ${currentPageTokenResponse.token}` };
const currentPageRequestOptions: RequestInit = { method: 'GET', headers: currentPageHeaders };
const pageResponse = await fetch(currentUrl, currentPageRequestOptions);
const pageText = await pageResponse.text();
let pageData: any;
try {
pageData = pageText ? JSON.parse(pageText) : {};
} catch (e) {
logger.error(`Failed to parse JSON from Azure RM page: ${currentUrl}`, pageText);
pageData = { rawResponse: pageText };
}
if (!pageResponse.ok) {
logger.error(`API error on Azure RM page ${currentUrl}:`, pageData);
throw new Error(`API error (${pageResponse.status}) during Azure RM pagination on ${currentUrl}: ${JSON.stringify(pageData)}`);
}
if (pageData.value && Array.isArray(pageData.value)) {
allValues = allValues.concat(pageData.value);
} else if (currentUrl === url && !pageData.nextLink) {
allValues.push(pageData);
} else if (currentUrl !== url) {
logger.info(`[Warning] Azure RM response from ${currentUrl} did not contain a 'value' array.`);
}
currentUrl = pageData.nextLink || null; // Azure uses nextLink
}
responseData = { allValues: allValues };
logger.info(`Finished fetching all Azure RM pages. Total items: ${allValues.length}`);
} else {
// Single page fetch for Azure RM
logger.info(`Fetching single page for Azure RM: ${url}`);
const apiResponse = await fetch(url, requestOptions);
const responseText = await apiResponse.text();
try {
responseData = responseText ? JSON.parse(responseText) : {};
} catch (e) {
logger.error(`Failed to parse JSON from single Azure RM page: ${url}`, responseText);
responseData = { rawResponse: responseText };
}
if (!apiResponse.ok) {
logger.error(`API error for Azure RM ${method} ${path}:`, responseData);
throw new Error(`API error (${apiResponse.status}) for Azure RM: ${JSON.stringify(responseData)}`);
}
}
}
// --- Format and Return Result ---
// For all requests, format as text
let resultText = `Result for ${apiType} API (${apiType === 'graph' ? effectiveGraphApiVersion : apiVersion}) - ${method} ${path}:\n\n`;
resultText += JSON.stringify(responseData, null, 2); // responseData already contains the correct structure for fetchAll Graph case
// Add pagination note if applicable (only for single page GET)
if (!fetchAll && method === 'get') {
const nextLinkKey = apiType === 'graph' ? '@odata.nextLink' : 'nextLink';
if (responseData && responseData[nextLinkKey]) { // Added check for responseData existence
resultText += `\n\nNote: More results are available. To retrieve all pages, add the parameter 'fetchAll: true' to your request.`;
}
}
return {
content: [{ type: "text" as const, text: resultText }],
};
} catch (error: any) {
logger.error(`Error in Lokka-Microsoft tool (apiType: ${apiType}, path: ${path}, method: ${method}):`, error); // Added more context to error log
// Try to determine the base URL even in case of error
if (!determinedUrl) {
determinedUrl = apiType === 'graph'
? `https://graph.microsoft.com/${effectiveGraphApiVersion}`
: "https://management.azure.com";
}
// Include error body if available from Graph SDK error
const errorBody = error.body ? (typeof error.body === 'string' ? error.body : JSON.stringify(error.body)) : 'N/A';
return {
content: [{
type: "text",
text: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
statusCode: error.statusCode || 'N/A', // Include status code if available from SDK error
errorBody: errorBody,
attemptedBaseUrl: determinedUrl
}),
}],
isError: true
};
}
},
);
// Add token management tools
server.tool(
"set-access-token",
"Set or update the access token for Microsoft Graph authentication. Use this when the MCP Client has obtained a fresh token through interactive authentication.",
{
accessToken: z.string().describe("The access token obtained from Microsoft Graph authentication"),
expiresOn: z.string().optional().describe("Token expiration time in ISO format (optional, defaults to 1 hour from now)")
},
async ({ accessToken, expiresOn }) => {
try {
const expirationDate = expiresOn ? new Date(expiresOn) : undefined;
if (authManager?.getAuthMode() === AuthMode.ClientProvidedToken) {
authManager.updateAccessToken(accessToken, expirationDate);
// Reinitialize the Graph client with the new token
const authProvider = authManager.getGraphAuthProvider();
graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
return {
content: [{
type: "text" as const,
text: "Access token updated successfully. You can now make Microsoft Graph requests on behalf of the authenticated user."
}],
};
} else {
return {
content: [{
type: "text" as const,
text: "Error: MCP Server is not configured for client-provided token authentication. Set USE_CLIENT_TOKEN=true in environment variables."
}],
isError: true
};
}
} catch (error: any) {
logger.error("Error setting access token:", error);
return {
content: [{
type: "text" as const,
text: `Error setting access token: ${error.message}`
}],
isError: true
};
}
}
);
server.tool(
"get-auth-status",
"Check the current authentication status and mode of the MCP Server and also returns the current graph permission scopes of the access token for the current session.",
{},
async () => {
try {
const authMode = authManager?.getAuthMode() || "Not initialized";
const isReady = authManager !== null;
const tokenStatus = authManager ? await authManager.getTokenStatus() : { isExpired: false };
return {
content: [{
type: "text" as const,
text: JSON.stringify({
authMode,
isReady,
supportsTokenUpdates: authMode === AuthMode.ClientProvidedToken,
tokenStatus: tokenStatus,
timestamp: new Date().toISOString()
}, null, 2)
}],
};
} catch (error: any) {
return {
content: [{
type: "text" as const,
text: `Error checking auth status: ${error.message}`
}],
isError: true
};
}
}
);
// Add tool for requesting additional Graph permissions
server.tool(
"add-graph-permission",
"Request additional Microsoft Graph permission scopes by performing a fresh interactive sign-in. This tool only works in interactive authentication mode and should be used if any Graph API call returns permissions related errors.",
{
scopes: z.array(z.string()).describe("Array of Microsoft Graph permission scopes to request (e.g., ['User.Read', 'Mail.ReadWrite', 'Directory.Read.All'])")
},
async ({ scopes }) => {
try {
// Check if we're in interactive mode
if (!authManager || authManager.getAuthMode() !== AuthMode.Interactive) {
const currentMode = authManager?.getAuthMode() || "Not initialized";
const clientId = process.env.CLIENT_ID;
let errorMessage = `Error: add-graph-permission tool is only available in interactive authentication mode. Current mode: ${currentMode}.\n\n`;
if (currentMode === AuthMode.ClientCredentials) {
errorMessage += `📋 To add permissions in Client Credentials mode:\n`;
errorMessage += `1. Open the Microsoft Entra admin center (https://entra.microsoft.com)\n`;
errorMessage += `2. Navigate to Applications > App registrations\n`;
errorMessage += `3. Find your application${clientId ? ` (Client ID: ${clientId})` : ''}\n`;
errorMessage += `4. Go to API permissions\n`;
errorMessage += `5. Click "Add a permission" and select Microsoft Graph\n`;
errorMessage += `6. Choose "Application permissions" and add the required scopes:\n`;
errorMessage += ` ${scopes.map(scope => `• ${scope}`).join('\n ')}\n`;
errorMessage += `7. Click "Grant admin consent" to approve the permissions\n`;
errorMessage += `8. Restart the MCP server to use the new permissions`;
} else if (currentMode === AuthMode.ClientProvidedToken) {
errorMessage += `📋 To add permissions in Client Provided Token mode:\n`;
errorMessage += `1. Obtain a new access token that includes the required scopes:\n`;
errorMessage += ` ${scopes.map(scope => `• ${scope}`).join('\n ')}\n`;
errorMessage += `2. When obtaining the token, ensure these scopes are included in the consent prompt\n`;
errorMessage += `3. Use the set-access-token tool to update the server with the new token\n`;
errorMessage += `4. The new token will include the additional permissions`;
} else {
errorMessage += `To use interactive permission requests, set USE_INTERACTIVE=true in environment variables and restart the server.`;
}
return {
content: [{
type: "text" as const,
text: errorMessage
}],
isError: true
};
}
// Validate scopes array
if (!scopes || scopes.length === 0) {
return {
content: [{
type: "text" as const,
text: "Error: At least one permission scope must be specified."
}],
isError: true
};
}
// Validate scope format (basic validation)
const invalidScopes = scopes.filter(scope => !scope.includes('.') || scope.trim() !== scope);
if (invalidScopes.length > 0) {
return {
content: [{
type: "text" as const,
text: `Error: Invalid scope format detected: ${invalidScopes.join(', ')}. Scopes should be in format like 'User.Read' or 'Mail.ReadWrite'.`
}],
isError: true
};
}
logger.info(`Requesting additional Graph permissions: ${scopes.join(', ')}`);
// Get current configuration with defaults for interactive auth
const tenantId = process.env.TENANT_ID || LokkaDefaultTenantId;
const clientId = process.env.CLIENT_ID || LokkaClientId;
const redirectUri = process.env.REDIRECT_URI || LokkaDefaultRedirectUri;
logger.info(`Using tenant ID: ${tenantId}, client ID: ${clientId} for interactive authentication`);
// Create a new interactive credential with the requested scopes
const { InteractiveBrowserCredential, DeviceCodeCredential } = await import("@azure/identity");
// Clear any existing auth manager to force fresh authentication
authManager = null;
graphClient = null;
// Request token with the new scopes - this will trigger interactive authentication
const scopeString = scopes.map(scope => `https://graph.microsoft.com/${scope}`).join(' ');
logger.info(`Requesting fresh token with scopes: ${scopeString}`);
console.log(`\n🔐 Requesting Additional Graph Permissions:`);
console.log(`Scopes: ${scopes.join(', ')}`);
console.log(`You will be prompted to sign in to grant these permissions.\n`);
let newCredential;
let tokenResponse;
try {
// Try Interactive Browser first - create fresh instance each time
newCredential = new InteractiveBrowserCredential({
tenantId: tenantId,
clientId: clientId,
redirectUri: redirectUri,
});
// Request token immediately after creating credential
tokenResponse = await newCredential.getToken(scopeString);
} catch (error) {
// Fallback to Device Code flow
logger.info("Interactive browser failed, falling back to device code flow");
newCredential = new DeviceCodeCredential({
tenantId: tenantId,
clientId: clientId,
userPromptCallback: (info) => {
console.log(`\n🔐 Additional Permissions Required:`);
console.log(`Please visit: ${info.verificationUri}`);
console.log(`And enter code: ${info.userCode}`);
console.log(`Requested scopes: ${scopes.join(', ')}\n`);
return Promise.resolve();
},
});
// Request token with device code credential
tokenResponse = await newCredential.getToken(scopeString);
}
if (!tokenResponse) {
return {
content: [{
type: "text" as const,
text: "Error: Failed to acquire access token with the requested scopes. Please check your permissions and try again."
}],
isError: true
};
}
// Create a completely new auth manager instance with the updated credential
const authConfig: AuthConfig = {
mode: AuthMode.Interactive,
tenantId,
clientId,
redirectUri
};
// Create a new auth manager instance
authManager = new AuthManager(authConfig);
// Manually set the credential to our new one with the additional scopes
(authManager as any).credential = newCredential;
// DO NOT call initialize() as it might interfere with our fresh token
// Instead, directly create the Graph client with the new credential
const authProvider = authManager.getGraphAuthProvider();
graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
// Get the token status to show the new scopes
const tokenStatus = await authManager.getTokenStatus();
logger.info(`Successfully acquired fresh token with additional scopes: ${scopes.join(', ')}`);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
message: "Successfully acquired additional Microsoft Graph permissions with fresh authentication",
requestedScopes: scopes,
tokenStatus: tokenStatus,
note: "A fresh sign-in was performed to ensure the new permissions are properly granted",
timestamp: new Date().toISOString()
}, null, 2)
}],
};
} catch (error: any) {
logger.error("Error requesting additional Graph permissions:", error);
return {
content: [{
type: "text" as const,
text: `Error requesting additional permissions: ${error.message}`
}],
isError: true
};
}
}
);
// Start the server with stdio transport
async function main() {
// Determine authentication mode based on environment variables
const useCertificate = process.env.USE_CERTIFICATE === 'true';
const useInteractive = process.env.USE_INTERACTIVE === 'true';
const useClientToken = process.env.USE_CLIENT_TOKEN === 'true';
const initialAccessToken = process.env.ACCESS_TOKEN;
let authMode: AuthMode;
// Ensure only one authentication mode is enabled at a time
const enabledModes = [
useClientToken,
useInteractive,
useCertificate
].filter(Boolean);
if (enabledModes.length > 1) {
throw new Error(
"Multiple authentication modes enabled. Please enable only one of USE_CLIENT_TOKEN, USE_INTERACTIVE, or USE_CERTIFICATE."
);
}
if (useClientToken) {
authMode = AuthMode.ClientProvidedToken;
if (!initialAccessToken) {
logger.info("Client token mode enabled but no initial token provided. Token must be set via set-access-token tool.");
}
} else if (useInteractive) {
authMode = AuthMode.Interactive;
} else if (useCertificate) {
authMode = AuthMode.Certificate;
} else {
// Check if we have client credentials environment variables
const hasClientCredentials = process.env.TENANT_ID && process.env.CLIENT_ID && process.env.CLIENT_SECRET;
if (hasClientCredentials) {
authMode = AuthMode.ClientCredentials;
} else {
// Default to interactive mode for better user experience
authMode = AuthMode.Interactive;
logger.info("No authentication mode specified and no client credentials found. Defaulting to interactive mode.");
}
}
logger.info(`Starting with authentication mode: ${authMode}`);
// Get tenant ID and client ID with defaults only for interactive mode
let tenantId: string | undefined;
let clientId: string | undefined;
if (authMode === AuthMode.Interactive) {
// Interactive mode can use defaults
tenantId = process.env.TENANT_ID || LokkaDefaultTenantId;
clientId = process.env.CLIENT_ID || LokkaClientId;
logger.info(`Interactive mode using tenant ID: ${tenantId}, client ID: ${clientId}`);
} else {
// All other modes require explicit values from environment variables
tenantId = process.env.TENANT_ID;
clientId = process.env.CLIENT_ID;
}
const clientSecret = process.env.CLIENT_SECRET;
const certificatePath = process.env.CERTIFICATE_PATH;
const certificatePassword = process.env.CERTIFICATE_PASSWORD; // optional
// Validate required configuration
if (authMode === AuthMode.ClientCredentials) {
if (!tenantId || !clientId || !clientSecret) {
throw new Error("Client credentials mode requires explicit TENANT_ID, CLIENT_ID, and CLIENT_SECRET environment variables");
}
} else if (authMode === AuthMode.Certificate) {
if (!tenantId || !clientId || !certificatePath) {
throw new Error("Certificate mode requires explicit TENANT_ID, CLIENT_ID, and CERTIFICATE_PATH environment variables");
}
}
// Note: Client token mode can start without a token and receive it later
const authConfig: AuthConfig = {
mode: authMode,
tenantId,
clientId,
clientSecret,
accessToken: initialAccessToken,
redirectUri: process.env.REDIRECT_URI,
certificatePath,
certificatePassword
};
authManager = new AuthManager(authConfig);
// Only initialize if we have required config (for client token mode, we can start without a token)
if (authMode !== AuthMode.ClientProvidedToken || initialAccessToken) {
await authManager.initialize();
// Initialize Graph Client
const authProvider = authManager.getGraphAuthProvider();
graphClient = Client.initWithMiddleware({
authProvider: authProvider,
});
logger.info(`Authentication initialized successfully using ${authMode} mode`);
} else {
logger.info("Started in client token mode. Use set-access-token tool to provide authentication token.");
}
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error("Fatal error in main():", error);
logger.error("Fatal error in main()", error);
process.exit(1);
});
```
--------------------------------------------------------------------------------
/website/static/img/logo.svg:
--------------------------------------------------------------------------------
```
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600"><foreignObject width="100%" height="100%" x="0" y="0" externalResourcesRequired="true"><div xmlns="http://www.w3.org/1999/xhtml" class="bg-transparent w-screen max-w-full aspect-square md:w-[350px] md:h-[350px] lg:w-[400px] lg:h-[400px] xl:w-[600px] xl:h-[600px]" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box transparent; background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: 600px; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: 600px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: 600px; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: 100%; max-width: 100%; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 45px; padding: 45px; padding-inline: 45px; paint-order: normal; perspective: none; perspective-origin: 300px 300px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 300px 300px; transform-style: flat; transition: all; translate: none; unicode-bidi: isolate; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: 600px; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"><div style="background: radial-gradient(circle, rgb(198, 248, 255) 0%, rgb(169, 255, 104) 100%) 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); border-radius: 300px; accent-color: auto; place-content: normal center; place-items: center normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: 510px; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 300px; border-end-start-radius: 300px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 300px; border-start-start-radius: 300px; inset: auto; box-decoration-break: slice; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.25) 0px 25px 50px -12px; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: flex; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: 510px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: 510px; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: hidden; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 255px 255px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 255px 255px; transform-style: flat; transition: all; translate: none; unicode-bidi: isolate; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: 510px; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;" class="w-full aspect-square overflow-hidden flex justify-center items-center shadow-2xl"><span style="transform: matrix(1, 0, 0, 1, 0, 0); accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: 400px; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: 400px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: 400px; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: auto; min-height: auto; min-inline-size: auto; min-width: auto; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 200px 200px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform-origin: 200px 200px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: 400px; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"><svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 24 24" fill="rgb(255, 255, 255)" stroke="#1e1e1e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot" fill-opacity="0.9" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: 400px; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: 400px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: 400px; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: content-box; overflow-wrap: normal; overflow: hidden; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 200px 200px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 200px 200px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: middle; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: 400px; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"><path d="M12 8V4H8" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: auto; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; direction: ltr; display: inline; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: auto; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: auto; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 0px 0px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 0px 0px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: auto; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"/><rect width="16" height="12" x="4" y="8" rx="2" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: 12px; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: inline; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: 12px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: 16px; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 0px 0px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: 2px; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 0px 0px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: 16px; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 4px; y: 8px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"/><path d="M2 14h2" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: auto; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; direction: ltr; display: inline; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: auto; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: auto; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 0px 0px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 0px 0px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: auto; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"/><path d="M20 14h2" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: auto; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; direction: ltr; display: inline; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: auto; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: auto; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 0px 0px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 0px 0px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: auto; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"/><path d="M15 13v2" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: auto; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; direction: ltr; display: inline; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: auto; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: auto; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 0px 0px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 0px 0px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: auto; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"/><path d="M9 13v2" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; alignment-baseline: auto; anchor-name: none; anchor-scope: none; animation-composition: replace; animation: 0s ease 0s 1 normal none running none; app-region: none; appearance: none; backdrop-filter: none; backface-visibility: visible; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); background-blend-mode: normal; baseline-shift: 0px; baseline-source: auto; block-size: auto; border-block-end: 0px solid rgb(229, 231, 235); border-block-start: 0px solid rgb(229, 231, 235); border-color: rgb(229, 231, 235); border-radius: 0px; border-style: solid; border-width: 0px; border-collapse: separate; border-end-end-radius: 0px; border-end-start-radius: 0px; border-image: none 100% / 1 / 0 stretch; border-inline-end: 0px solid rgb(229, 231, 235); border-inline-start: 0px solid rgb(229, 231, 235); border-start-end-radius: 0px; border-start-start-radius: 0px; inset: auto; box-decoration-break: slice; box-shadow: none; box-sizing: border-box; break-after: auto; break-before: auto; break-inside: auto; buffered-rendering: auto; caption-side: top; caret-color: rgba(48, 48, 48, 0.8); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgba(48, 48, 48, 0.8); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-rendering: auto; columns: auto; gap: normal; column-rule: 0px rgba(48, 48, 48, 0.8); column-span: none; contain-intrinsic-block-size: none; contain-intrinsic-size: none; contain-intrinsic-inline-size: none; container: none; content: normal; cursor: auto; cx: 0px; cy: 0px; direction: ltr; display: inline; dominant-baseline: auto; empty-cells: show; field-sizing: fixed; fill: rgb(255, 255, 255); fill-opacity: 0.9; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font-family: __Space_Grotesk_0aa4ae, __Space_Grotesk_Fallback_0aa4ae; font-kerning: auto; font-optical-sizing: auto; font-palette: normal; font-size: 15.9px; font-size-adjust: none; font-stretch: 100%; font-style: normal; font-synthesis: weight style small-caps; font-variant: normal; font-weight: 400; grid: none; grid-area: auto; height: auto; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; initial-letter: normal; inline-size: auto; inset-block: auto; inset-inline: auto; interpolate-size: numeric-only; isolation: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; line-height: 24px; list-style: outside none disc; margin-block: 0px; margin: 0px; margin-inline: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; object-view-box: none; offset: normal; opacity: 1; order: 0; orphans: 2; outline: rgba(48, 48, 48, 0.8) none 0px; outline-offset: 0px; overflow-anchor: auto; overflow-clip-margin: 0px; overflow-wrap: normal; overflow: visible; overlay: none; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; padding-block: 0px; padding: 0px; padding-inline: 0px; paint-order: normal; perspective: none; perspective-origin: 0px 0px; pointer-events: auto; position: static; position-anchor: auto; position-area: none; position-try: none; position-visibility: always; r: 0px; resize: none; rotate: none; ruby-align: space-around; ruby-position: over; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-initial-target: none; scroll-margin-block: 0px; scroll-margin-inline: 0px; scroll-padding-block: auto; scroll-padding-inline: auto; scroll-timeline: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; speak: normal; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: rgb(30, 30, 30); stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: round; stroke-linejoin: round; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 2px; tab-size: 4; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-box: normal; text-decoration: none solid rgba(48, 48, 48, 0.8); text-decoration-skip-ink: auto; text-emphasis: none rgba(48, 48, 48, 0.8); text-emphasis-position: over; text-indent: 0px; text-overflow: clip; text-rendering: auto; text-shadow: none; text-size-adjust: 100%; text-spacing-trim: normal; text-transform: none; text-underline-position: auto; text-wrap: wrap; timeline-scope: none; touch-action: auto; transform: none; transform-origin: 0px 0px; transform-style: flat; transition: all; translate: none; unicode-bidi: normal; user-select: auto; vector-effect: none; vertical-align: baseline; view-timeline: none; view-transition-class: none; view-transition-name: none; visibility: visible; white-space-collapse: collapse; widows: 2; width: auto; will-change: auto; word-break: normal; word-spacing: 0px; writing-mode: horizontal-tb; x: 0px; y: 0px; z-index: auto; zoom: 1; border-spacing: 0px; -webkit-border-image: none; -webkit-box-align: stretch; -webkit-box-decoration-break: slice; -webkit-box-direction: normal; -webkit-box-flex: 0; -webkit-box-ordinal-group: 1; -webkit-box-orient: horizontal; -webkit-box-pack: start; -webkit-font-smoothing: auto; -webkit-line-break: auto; -webkit-line-clamp: none; -webkit-locale: "en"; -webkit-mask-box-image-source: none; -webkit-mask-box-image-slice: 0 fill; -webkit-mask-box-image-width: auto; -webkit-mask-box-image-outset: 0; -webkit-mask-box-image-repeat: stretch; -webkit-print-color-adjust: economy; -webkit-rtl-ordering: logical; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-text-combine: none; -webkit-text-decorations-in-effect: none; -webkit-text-fill-color: rgba(48, 48, 48, 0.8); -webkit-text-orientation: vertical-right; -webkit-text-security: none; -webkit-text-stroke: 0px rgba(48, 48, 48, 0.8); -webkit-user-drag: auto; -webkit-user-modify: read-only; -webkit-writing-mode: horizontal-tb;"/></svg></span></div></div></foreignObject></svg>
```