├── .cursor └── rules │ ├── node-testing.mdc │ └── typescript-style-guide.mdc ├── .eslintrc ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── mise.toml ├── package.json ├── packages └── local-server │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ ├── auth │ │ ├── auth.ts │ │ ├── error.ts │ │ ├── oauth-cache.ts │ │ ├── token-store.ts │ │ └── types.ts │ ├── common │ │ ├── client.ts │ │ ├── errors.ts │ │ └── version.ts │ ├── config │ │ └── config.ts │ ├── configure.ts │ ├── configure │ │ ├── client │ │ │ ├── claude.ts │ │ │ ├── cursor.ts │ │ │ ├── vscode.ts │ │ │ └── windsurf.ts │ │ └── index.ts │ ├── index.ts │ ├── log │ │ └── logger.ts │ ├── server.ts │ ├── test │ │ ├── auth │ │ │ ├── auth.test.ts │ │ │ ├── authorize.test.ts │ │ │ ├── oauth-cache.test.ts │ │ │ └── token-store.test.ts │ │ ├── cli.test.ts │ │ ├── common │ │ │ ├── client.test.ts │ │ │ └── errors.test.ts │ │ ├── config │ │ │ └── config.test.ts │ │ ├── configure │ │ │ └── vscode.test.ts │ │ ├── formatters │ │ │ ├── chat-formatter.test.ts │ │ │ └── search-formatter.test.ts │ │ ├── log │ │ │ └── logger.test.ts │ │ ├── mocks │ │ │ ├── handlers.ts │ │ │ └── setup.ts │ │ ├── server.test.ts │ │ ├── tools │ │ │ ├── chat.test.ts │ │ │ ├── people_profile_search.test.ts │ │ │ └── search.test.ts │ │ ├── util │ │ │ └── preflight.test.ts │ │ ├── validate-flags.test.ts │ │ └── xdg │ │ │ └── xdg.test.ts │ ├── tools │ │ ├── chat.ts │ │ ├── people_profile_search.ts │ │ └── search.ts │ ├── types │ │ └── index.ts │ ├── util │ │ ├── object.ts │ │ └── preflight.ts │ └── xdg │ │ └── xdg.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.cursor/rules/node-testing.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: **/*.test.ts,src/test/** 4 | alwaysApply: false 5 | --- 6 | # Testing Style Guide 7 | 8 | ### Testing Frameworks 9 | 10 | - use vitest 11 | 12 | ### Expectations 13 | 14 | Always prefer inline snapshot matching whenever possible. If necessary, have local normalization/sanitization functions to replace things that are noisy or unstable, like timestamps or error stacks. 15 | 16 | Always prefer `toThrowMatchingInlineSnapshot()` over naked `toThrow()` or `toThrow()` with a regex. 17 | 18 | ### Test Organization 19 | 20 | Tests are in `src/test` 21 | 22 | ### Mocking HTTP Requests 23 | 24 | We use `msw`. We use v2 so don't use the v1 api. 25 | 26 | ### File I/O 27 | 28 | Use node-fixturify against a temp directory. Do not mock file system operations. Set XDG variables like XDG_DATA_HOME, XDG_STATE_HOME, XDG_CONFIG_HOME &c. so that files are written to the temp directory. 29 | 30 | IMPORTANT: when setting an XDG variable be sure to call `Logger.reset`. The logging utilities use a singleton that only checks for the XDG directory on initialization. Be sure to call `Logger.reset` in `afterEach` and not in `beforeEach` so you don't poison future tests. 31 | 32 | Here are the usage docs from fixturify's README 33 | 34 | ```js 35 | const fixturify = require('fixturify') 36 | 37 | const obj = { 38 | 'foo.txt': 'foo.txt contents', 39 | 'subdir': { 40 | 'bar.txt': 'bar.txt contents' 41 | } 42 | } 43 | 44 | fixturify.writeSync('testdir', obj) // write it to disk 45 | 46 | fixturify.readSync('testdir') // => deep-equals obj 47 | 48 | fixturify.readSync('testdir', { globs: ['foo*'] }) // glob support 49 | // => { foo.txt: 'foo.text contents' } 50 | 51 | fixturify.readSync('testdir', { ignore: ['foo*'] }) // glob support 52 | // => { subdir: { bar.txt: 'bar.text contents' } } 53 | 54 | fixturify.writeSync('testDir', { 55 | 'subdir': { 'bar.txt': null } 56 | }) // remove subdir/bar.txt 57 | 58 | fixturify.readSync('testdir') // => { foo.txt: 'foo.text contents' } 59 | 60 | fixturify.writeSync('testDir', { 61 | 'subdir': null 62 | }) // remove subdir/ 63 | ``` 64 | 65 | ```js 66 | const fixturify = require('fixturify') 67 | 68 | const obj = { 69 | 'subdir': { 70 | 'foo.txt': 'foo.txt contents' 71 | }, 72 | 'emptydir': {} 73 | } 74 | 75 | fixturify.writeSync('testdir', obj) // write it to disk 76 | 77 | fixturify.readSync('testdir', { ignoreEmptyDirs: true }) 78 | // => { subdir: { foo.txt': 'foo.txt contents' } } 79 | ``` 80 | 81 | Keep in mind that we always use ESM and always write in TypeScript, so convert the usage appropriately. 82 | 83 | 84 | -------------------------------------------------------------------------------- /.cursor/rules/typescript-style-guide.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.ts,package.json 4 | alwaysApply: false 5 | --- 6 | ### Dependencies 7 | 8 | - use pnpm 9 | 10 | ### Function Organization 11 | 12 | Put all publicly exported functions and types at the top of the file, below imports. 13 | Put private, non-exported functions below all the publicly exported functions. -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "plugins": [ 13 | "@typescript-eslint", 14 | "prettier" 15 | ], 16 | "parserOptions": { 17 | "ecmaVersion": 2019, 18 | "sourceType": "module", 19 | "project": "./tsconfig.json" 20 | }, 21 | "ignorePatterns": [ 22 | ".eslintrc.js", 23 | "prettier.config.js" 24 | ], 25 | "rules": { 26 | "@typescript-eslint/ban-ts-ignore": "off", 27 | "@typescript-eslint/type-annotation-spacing": "off", 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "no-unused-vars": "off", 30 | "@typescript-eslint/no-unused-vars": [ 31 | "warn", 32 | { 33 | "args": "none" 34 | } 35 | ], 36 | "@typescript-eslint/adjacent-overload-signatures": "error", 37 | "@typescript-eslint/ban-types": "error", 38 | "camelcase": "off", 39 | "@typescript-eslint/consistent-type-assertions": "error", 40 | "no-array-constructor": "off", 41 | "@typescript-eslint/no-array-constructor": "error", 42 | "no-empty": "off", 43 | "no-empty-function": "off", 44 | "@typescript-eslint/explicit-module-boundary-types": "off", 45 | "@typescript-eslint/no-empty-function": "error", 46 | "@typescript-eslint/no-empty-interface": "error", 47 | "@typescript-eslint/no-explicit-any": "off", 48 | "@typescript-eslint/no-inferrable-types": "error", 49 | "@typescript-eslint/no-misused-new": "error", 50 | "@typescript-eslint/no-namespace": "error", 51 | "@typescript-eslint/no-non-null-assertion": "off", 52 | "@typescript-eslint/no-this-alias": "error", 53 | "no-use-before-define": "off", 54 | "@typescript-eslint/no-use-before-define": "off", 55 | "@typescript-eslint/no-var-requires": "error", 56 | "@typescript-eslint/prefer-namespace-keyword": "error", 57 | "@typescript-eslint/triple-slash-reference": "error", 58 | "@typescript-eslint/no-floating-promises": [ 59 | "error" 60 | ], 61 | "no-var": "error", 62 | "prefer-const": "error", 63 | "prefer-rest-params": "error", 64 | "prefer-spread": "error" 65 | }, 66 | "overrides": [ 67 | { 68 | "files": [ 69 | "*.js" 70 | ], 71 | "rules": { 72 | "@typescript-eslint/no-var-requires": "off" 73 | } 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Prevent eslintrc from appearing in GitHub language calculation 2 | # (There seems to be no way to prevent language detection from 3 | # falsely calling the CLI commands Javascript due to the shebang line) 4 | .eslintrc.js linguist-documentation 5 | prettier.config.js linguist-documentation 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gleanwork/gleanwork-reviewers 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve the MCP server 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | 11 | 12 | 13 | ## Self-Service Checklist 14 | 15 | 16 | 17 | - [ ] I have verified my environment variables are correctly set 18 | - `GLEAN_INSTANCE` is set to my Glean instance (note: `GLEAN_SUBDOMAIN` is still supported for backwards compatibility) 19 | - `GLEAN_API_TOKEN` is a valid, non-expired API token 20 | - (Optional) `GLEAN_ACT_AS` is correctly set if using impersonation 21 | - [ ] I have tested the MCP server locally using `pnpm inspector` and confirmed the issue occurs there as well 22 | - [ ] I have checked for similar issues in the issue tracker 23 | - [ ] I have updated to the latest version of the MCP server 24 | 25 | ## Steps to Reproduce 26 | 27 | 28 | 29 | 1. 30 | 2. 31 | 3. 32 | 33 | ## Expected Behavior 34 | 35 | 36 | 37 | ## Actual Behavior 38 | 39 | 40 | 41 | ## Environment Information 42 | 43 | - Node.js version: 44 | - NPM/PNPM/Yarn version: 45 | - MCP server version: 46 | - Operating System: 47 | - MCP client (if applicable): 48 | 49 | ## Additional Context 50 | 51 | 52 | 53 | ## MCP Client Configuration 54 | 55 | 56 | 57 | ```json 58 | { 59 | "mcpServers": { 60 | "glean": { 61 | "command": "npx", 62 | "args": ["-y", "@gleanwork/mcp-server"], 63 | "env": { 64 | "GLEAN_INSTANCE": "", 65 | "GLEAN_API_TOKEN": "" 66 | } 67 | } 68 | } 69 | } 70 | ``` 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for the MCP server 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | ## Feature Description 10 | 11 | 12 | ## Problem Statement 13 | 14 | 15 | ## Proposed Solution 16 | 17 | 18 | ## Alternatives Considered 19 | 20 | 21 | ## Additional Context 22 | 23 | 24 | ## Impact 25 | 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue 6 | 7 | 8 | 9 | Fixes # 10 | 11 | ## Motivation and Context 12 | 13 | 14 | 15 | ## Type of Change 16 | 17 | 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 22 | - [ ] Documentation update 23 | - [ ] Code refactoring 24 | - [ ] Build/CI pipeline changes 25 | - [ ] Other (please describe): 26 | 27 | ## How Has This Been Tested? 28 | 29 | 30 | 31 | - [ ] Unit tests 32 | - [ ] Integration tests 33 | - [ ] Manual testing 34 | - [ ] Other (please describe): 35 | 36 | ## Checklist 37 | 38 | 39 | 40 | - [ ] My code follows the code style of this project 41 | - [ ] I have updated the documentation accordingly 42 | - [ ] I have added tests to cover my changes 43 | - [ ] I have checked for potential breaking changes and addressed them 44 | 45 | ## Screenshots (if appropriate) 46 | 47 | 48 | 49 | ## Additional Notes 50 | 51 | 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [20, 22, 24] 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up mise 21 | uses: jdx/mise-action@v2 22 | with: 23 | cache: true 24 | mise_toml: | 25 | [tools] 26 | node = "${{ matrix.node-version }}" 27 | pnpm = "10" 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - name: Install dependencies 32 | run: pnpm install --frozen-lockfile 33 | 34 | - name: Run linter 35 | run: pnpm lint 36 | 37 | - name: Build 38 | run: pnpm build 39 | 40 | - name: Run tests 41 | run: pnpm test 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | sandbox 4 | sand\ box 5 | debug.log 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | package-log.json 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "bracketSpacing": true, 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | "recommendations": [ 4 | // For ESLint 5 | "dbaeumer.vscode-eslint", 6 | // For bleeding edge Typescript features 7 | "ms-vscode.vscode-typescript-next", 8 | // For better editing of the README and other markdown files 9 | "yzhang.markdown-all-in-one", 10 | // For auto-completes when typing out paths 11 | "christian-kohler.path-intellisense", 12 | // For auto-formatting 13 | "esbenp.prettier-vscode", 14 | // For support editing any YAML config or other files 15 | "redhat.vscode-yaml" 16 | ], 17 | "unwantedRecommendations": [] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "node", 7 | "request": "launch", 8 | "name": "Debug Current Test File", 9 | "autoAttachChildProcesses": true, 10 | "skipFiles": ["/**", "**/node_modules/**"], 11 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 12 | "args": ["run", "${relativeFile}", "--no-file-parallelism"], 13 | "smartStep": true, 14 | "console": "integratedTerminal" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Debug CLI (auth)", 20 | "autoAttachChildProcesses": true, 21 | "skipFiles": ["/**", "**/node_modules/**"], 22 | "program": "build/index.js", 23 | "args": ["--trace", "auth"], 24 | "smartStep": true, 25 | "console": "integratedTerminal" 26 | }, 27 | { 28 | "type": "node", 29 | "request": "launch", 30 | "name": "Debug CLI (auth-test)", 31 | "autoAttachChildProcesses": true, 32 | "skipFiles": ["/**", "**/node_modules/**"], 33 | "program": "build/index.js", 34 | "args": ["--trace", "auth-test"], 35 | "smartStep": true, 36 | "console": "integratedTerminal" 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use the local version of Typescript 3 | "typescript.tsdk": "node_modules\\typescript\\lib", 4 | 5 | // ESM requires extensions on import paths to work, 6 | // these options tell VSCode to prefer adding extensions 7 | // on auto-import. 8 | "typescript.preferences.importModuleSpecifierEnding": "js", 9 | "javascript.preferences.importModuleSpecifierEnding": "js", 10 | 11 | // Make sure ESLint runs on target files. 12 | "eslint.validate": ["javascript", "typescript"], 13 | // Check JavaScript by default (using the Typescript engine) 14 | "js/ts.implicitProjectConfig.checkJs": true, 15 | 16 | // Auto-format an fix files 17 | "editor.defaultFormatter": "esbenp.prettier-vscode", 18 | "editor.formatOnSave": true, 19 | "editor.codeActionsOnSave": { 20 | "source.fixAll.eslint": "explicit" 21 | }, 22 | 23 | // Let VSCode auto-update import paths when you move files around 24 | "typescript.updateImportsOnFileMove.enabled": "always", 25 | "javascript.updateImportsOnFileMove.enabled": "always" 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ## v0.7.1 (2025-05-29) 7 | 8 | #### :bug: Bug Fix 9 | * [#132](https://github.com/gleanwork/mcp-server/pull/132) fix(cli): Fixes .env file and process.env support in configure ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 10 | 11 | #### Committers: 1 12 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 13 | 14 | 15 | ## v0.7.0 (2025-05-23) 16 | 17 | #### :rocket: Enhancement 18 | * [#126](https://github.com/gleanwork/mcp-server/pull/126) feat: Implements local configuration for vscode (to complement the global one) ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 19 | * [#125](https://github.com/gleanwork/mcp-server/pull/125) feat(configure): add VS Code MCP client support ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 20 | 21 | #### Committers: 3 22 | - David J. Hamilton ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 23 | - Robert Jackson ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 24 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 25 | 26 | 27 | ## v0.6.1 (2025-05-15) 28 | 29 | #### :rocket: Enhancement 30 | * [#107](https://github.com/gleanwork/mcp-server/pull/107) fix: Making preflight validation message more clear ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 31 | 32 | #### Committers: 1 33 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 34 | 35 | 36 | ## v0.6.0 (2025-05-14) 37 | 38 | #### :rocket: Enhancement 39 | * [#105](https://github.com/gleanwork/mcp-server/pull/105) Add `--instance` preflight validation for `npx @gleanwork/mcp-server configure` ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 40 | * [#104](https://github.com/gleanwork/mcp-server/pull/104) Add `--instance` and `--token` options to `npx @gleanwork/mcp-server server` ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 41 | * [#102](https://github.com/gleanwork/mcp-server/pull/102) Add explicit `npx @gleanwork/mcp-server server` command (as the default) ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 42 | 43 | #### :bug: Bug Fix 44 | * [#103](https://github.com/gleanwork/mcp-server/pull/103) Fix short flag for `--instance` in `npx @gleanwork/mcp-server server` to be `-i` ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 45 | 46 | #### :house: Internal 47 | * [#101](https://github.com/gleanwork/mcp-server/pull/101) build(tooling): migrate from volta to mise ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 48 | * [#99](https://github.com/gleanwork/mcp-server/pull/99) feat(auth-test): readability ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 49 | * [#98](https://github.com/gleanwork/mcp-server/pull/98) fix(test): Run auth tests isolated ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 50 | 51 | #### Committers: 2 52 | - David J. Hamilton ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 53 | - Robert Jackson ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 54 | 55 | 56 | ## v0.5.0 (2025-05-12) 57 | 58 | #### :boom: Breaking Change 59 | * [#97](https://github.com/gleanwork/mcp-server/pull/97) chore: Bumping node, pinning to lowest version, updating ci matrix ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 60 | 61 | #### Committers: 1 62 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 63 | 64 | 65 | ## v0.4.0 (2025-05-11) 66 | 67 | #### :rocket: Enhancement 68 | 69 | - [#89](https://github.com/gleanwork/mcp-server/pull/89) feat: Adds people search tool ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 70 | - [#87](https://github.com/gleanwork/mcp-server/pull/87) task: Renaming tools to adopt more conventional tool names ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 71 | - [#86](https://github.com/gleanwork/mcp-server/pull/86) task: Updates usages of domain/subdomain to instance ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 72 | - [#84](https://github.com/gleanwork/mcp-server/pull/84) task: Simplifies schemas to provide a more consistent tool invocation ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 73 | - [#56](https://github.com/gleanwork/mcp-server/pull/56) chore: Migrates to @gleanwork/api-client ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 74 | 75 | #### :bug: Bug Fix 76 | 77 | - [#84](https://github.com/gleanwork/mcp-server/pull/84) task: Simplifies schemas to provide a more consistent tool invocation ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 78 | 79 | #### :house: Internal 80 | 81 | - [#90](https://github.com/gleanwork/mcp-server/pull/90) internal: Fixes issue template ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 82 | - [#88](https://github.com/gleanwork/mcp-server/pull/88) task: Adding CODEOWNERS ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 83 | - [#85](https://github.com/gleanwork/mcp-server/pull/85) task: Gate OAuth behind an env var ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 84 | - [#81](https://github.com/gleanwork/mcp-server/pull/81) chore: remove node-fetch dep ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 85 | - [#77](https://github.com/gleanwork/mcp-server/pull/77) chore: cursor rules ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 86 | - [#47](https://github.com/gleanwork/mcp-server/pull/47) chore(test): normalize version output in CLI tests ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 87 | 88 | #### Committers: 3 89 | 90 | - David J. Hamilton ([@david-hamilton-glean](https://github.com/david-hamilton-glean)) 91 | - Robert Jackson ([@robert-jackson-glean](https://github.com/robert-jackson-glean)) 92 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 93 | 94 | ## v0.3.0 (2025-04-15) 95 | 96 | #### :rocket: Enhancement 97 | 98 | - [#38](https://github.com/gleanwork/mcp-server/pull/38) feat: Adds the ability to run an configure command to configure the MCP Server ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 99 | 100 | #### Committers: 1 101 | 102 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 103 | 104 | ## v0.2.0 (2025-03-31) 105 | 106 | #### :rocket: Enhancement 107 | 108 | - [#23](https://github.com/gleanwork/mcp-server/pull/23) Provides better handling of invalid tokens. Also improves test infra. ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 109 | 110 | #### :bug: Bug Fix 111 | 112 | - [#23](https://github.com/gleanwork/mcp-server/pull/23) Provides better handling of invalid tokens. Also improves test infra. ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 113 | - [#22](https://github.com/gleanwork/mcp-server/pull/22) Fix schema validation errors ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 114 | 115 | #### :house: Internal 116 | 117 | - [#23](https://github.com/gleanwork/mcp-server/pull/23) Provides better handling of invalid tokens. Also improves test infra. ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 118 | 119 | #### Committers: 1 120 | 121 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 122 | 123 | ## v0.1.1 (2025-03-22) 124 | 125 | #### :bug: Bug Fix 126 | 127 | - [#14](https://github.com/gleanwork/mcp-server/pull/14) fix: Updates tool names in request handler to match new names ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 128 | 129 | #### :house: Internal 130 | 131 | - [#15](https://github.com/gleanwork/mcp-server/pull/15) internal: Adding issue, pull request, and feature templates for GitHub ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 132 | 133 | #### Committers: 1 134 | 135 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 136 | 137 | ## v0.1.0 (2025-03-21) 138 | 139 | ## v0.1.0-alpha.6 (2025-03-18) 140 | 141 | #### :bug: Bug Fix 142 | 143 | - [#5](https://github.com/gleanwork/mcp-server/pull/5) fix: Adds defaults for search.pageSize (otherwise no results are returned) ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 144 | 145 | #### Committers: 1 146 | 147 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 148 | 149 | ## v0.1.0-alpha.4 (2025-03-17) 150 | 151 | ## vv0.1.0-alpha.2 (2025-03-14) 152 | 153 | ## v0.1.0-alpha.1 (2025-03-14) 154 | 155 | #### :rocket: Enhancement 156 | 157 | - [#1](https://github.com/gleanwork/mcp-server/pull/1) Glean MCP Server implementation ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 158 | 159 | #### :house: Internal 160 | 161 | - [#2](https://github.com/gleanwork/mcp-server/pull/2) internal: Adds release-it for package releases ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 162 | 163 | #### Committers: 1 164 | 165 | - Steve Calvert ([@steve-calvert-glean](https://github.com/steve-calvert-glean)) 166 | 167 | # Changelog 168 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to @gleanwork/mcp-server 2 | 3 | Thank you for your interest in contributing to the Glean MCP Server! This document provides guidelines and instructions for development. 4 | 5 | ## Development Setup 6 | 7 | 1. Clone the repository: 8 | 9 | ```bash 10 | git clone https://github.com/gleanwork/mcp-server.git 11 | cd mcp-server 12 | ``` 13 | 14 | 1. Ensure `node` and `pnpm` are installed. This project has a built-in 15 | [mise](http://mise.jdx.dev/) config file that you can use if you'd like 16 | (though it is not required): 17 | 18 | ``` 19 | mise trust 20 | mise install 21 | ``` 22 | 23 | 1. Install dependencies: 24 | 25 | ```bash 26 | pnpm install 27 | ``` 28 | 29 | 1. Run tests: 30 | 31 | ```bash 32 | pnpm test 33 | ``` 34 | 35 | 1. Build the project: 36 | 37 | ```bash 38 | pnpm run build 39 | ``` 40 | 41 | ## Running the Server Locally 42 | 43 | The server communicates via stdio, making it ideal for integration with AI models and other tools: 44 | 45 | ```bash 46 | node build/index.js 47 | ``` 48 | 49 | Input and output follow the JSON-RPC 2.0 protocol, with each message on a new line. 50 | 51 | ## Making Changes 52 | 53 | 1. Fork the repository 54 | 2. Create your feature branch: `git checkout -b feature/my-feature` 55 | 3. Commit your changes: `git commit -am 'Add new feature'` 56 | 4. Push to the branch: `git push origin feature/my-feature` 57 | 5. Submit a pull request 58 | 59 | ## Code Style 60 | 61 | - Use TypeScript for all new code 62 | - Follow the existing code style (enforced by ESLint and Prettier) 63 | - Include JSDoc comments for public APIs 64 | - Write tests for new functionality 65 | 66 | ## Testing 67 | 68 | - Add unit tests for new features 69 | - Ensure all tests pass before submitting a pull request 70 | - Use the provided test utilities and fixtures 71 | 72 | ## Documentation 73 | 74 | - Update documentation for any changed functionality 75 | - Include examples for new features 76 | - Keep the README.md and API documentation up to date 77 | 78 | ## Need Help? 79 | 80 | - Documentation: [docs.glean.com](https://docs.glean.com) 81 | - Issues: [GitHub Issues](https://github.com/gleanwork/mcp-server/issues) 82 | - Email: [support@glean.com](mailto:support@glean.com) 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025, Glean Technologies Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @gleanwork/mcp-server 2 | 3 | ![MCP Server](https://badge.mcpx.dev?type=server 'MCP Server') 4 | ![CI Build](https://github.com/gleanwork/mcp-server/actions/workflows/ci.yml/badge.svg) 5 | [![npm version](https://badge.fury.io/js/@gleanwork%2Fmcp-server.svg)](https://badge.fury.io/js/@gleanwork%2Fmcp-server) 6 | [![License](https://img.shields.io/npm/l/@gleanwork%2Fmcp-server.svg)](https://github.com/gleanwork/mcp-server/blob/main/LICENSE) 7 | 8 | The Glean MCP Server is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that provides seamless integration with Glean's enterprise knowledge. 9 | 10 | ## Features 11 | 12 | - **Company Search**: Access Glean's powerful content search capabilities 13 | - **People Profile Search**: Access Glean's people directory 14 | - **Chat**: Interact with Glean's AI assistant 15 | - **MCP Compliant**: Implements the Model Context Protocol specification 16 | 17 | ## Tools 18 | 19 | - ### company_search 20 | 21 | Search Glean's content index using the Glean Search API. This tool allows you to query Glean's content index with various filtering and configuration options. 22 | 23 | - ### chat 24 | 25 | Interact with Glean's AI assistant using the Glean Chat API. This tool allows you to have conversational interactions with Glean's AI, including support for message history, citations, and various configuration options. 26 | 27 | - ### people_profile_search 28 | 29 | Search Glean's People directory to find employee information. 30 | 31 | ## Configuration 32 | 33 | ### API Tokens 34 | 35 | You'll need Glean [API credentials](https://developers.glean.com/client/authentication#glean-issued-tokens), and specifically a [user-scoped API token](https://developers.glean.com/client/authentication#user). API Tokens require the following scopes: `chat`, `search`. You should speak to your Glean administrator to provision these tokens. 36 | 37 | ### Configure Environment Variables 38 | 39 | 1. Set up your Glean API credentials: 40 | 41 | ```bash 42 | export GLEAN_INSTANCE=instance_name 43 | export GLEAN_API_TOKEN=your_api_token 44 | ``` 45 | 46 | Note: For backward compatibility, `GLEAN_SUBDOMAIN` is still supported, but `GLEAN_INSTANCE` is preferred. 47 | 48 | 1. (Optional) For [global tokens](https://developers.glean.com/indexing/authentication/permissions#global-tokens) that support impersonation: 49 | 50 | ```bash 51 | export GLEAN_ACT_AS=user@example.com 52 | ``` 53 | 54 | ## Client Configuration 55 | 56 | You can use the built-in configuration tool to automatically set up Glean for your MCP client: 57 | 58 | ```bash 59 | # Configure for Cursor 60 | npx @gleanwork/mcp-server configure --client cursor --token your_api_token --instance instance_name 61 | 62 | # Configure for Claude Desktop 63 | npx @gleanwork/mcp-server configure --client claude --token your_api_token --instance instance_name 64 | 65 | # Configure for VS Code 66 | npx @gleanwork/mcp-server configure --client vscode --token your_api_token --instance instance_name 67 | 68 | # Configure for Windsurf 69 | npx @gleanwork/mcp-server configure --client windsurf --token your_api_token --instance instance_name 70 | ``` 71 | 72 | Alternatively, you can use an environment file: 73 | 74 | ```bash 75 | npx @gleanwork/mcp-server configure --client cursor --env path/to/.env.glean 76 | ``` 77 | 78 | The environment file should contain: 79 | 80 | ```bash 81 | GLEAN_INSTANCE=instance_name 82 | GLEAN_API_TOKEN=your_api_token 83 | ``` 84 | 85 | After configuration: 86 | 87 | - For Cursor: Restart Cursor and the agent will have access to Glean tools 88 | - For Claude Desktop: Restart Claude and use the hammer icon to access Glean tools 89 | - For Windsurf: Open Settings > Advanced Settings, scroll to Cascade section, and press refresh 90 | 91 | ## MCP Client Configuration 92 | 93 | To configure this MCP server in your MCP client (such as Claude Desktop, Windsurf, Cursor, etc.), add the following configuration to your MCP client settings: 94 | 95 | ```json 96 | { 97 | "mcpServers": { 98 | "glean": { 99 | "command": "npx", 100 | "args": ["-y", "@gleanwork/mcp-server"], 101 | "env": { 102 | "GLEAN_INSTANCE": "", 103 | "GLEAN_API_TOKEN": "" 104 | } 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | Replace the environment variable values with your actual Glean credentials. 111 | 112 | ### Debugging 113 | 114 | Since MCP servers communicate over stdio, debugging can be challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script: 115 | 116 | ```bash 117 | npm run inspector 118 | ``` 119 | 120 | The Inspector will provide a URL to access debugging tools in your browser. 121 | 122 | ## Contributing 123 | 124 | Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. 125 | 126 | ## License 127 | 128 | MIT License - see the [LICENSE](LICENSE) file for details 129 | 130 | ## Support 131 | 132 | - Documentation: [docs.glean.com](https://docs.glean.com) 133 | - Issues: [GitHub Issues](https://github.com/gleanwork/mcp-server/issues) 134 | - Email: [support@glean.com](mailto:support@glean.com) 135 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases are mostly automated using 4 | [release-it](https://github.com/release-it/release-it/) and 5 | [lerna-changelog](https://github.com/lerna/lerna-changelog/). 6 | 7 | ## Preparation 8 | 9 | Since the majority of the actual release process is automated, the primary 10 | remaining task prior to releasing is confirming that all pull requests that 11 | have been merged since the last release have been labeled with the appropriate 12 | `lerna-changelog` labels and the titles have been updated to ensure they 13 | represent something that would make sense to our users. Some great information 14 | on why this is important can be found at 15 | [keepachangelog.com](https://keepachangelog.com/en/1.0.0/), but the overall 16 | guiding principle here is that changelogs are for humans, not machines. 17 | 18 | When reviewing merged PR's the labels to be used are: 19 | 20 | * breaking - Used when the PR is considered a breaking change. 21 | * enhancement - Used when the PR adds a new feature or enhancement. 22 | * bug - Used when the PR fixes a bug included in a previous release. 23 | * documentation - Used when the PR adds or updates documentation. 24 | * internal - Used for internal changes that still require a mention in the 25 | changelog/release notes. 26 | 27 | ## Release 28 | 29 | Once the prep work is completed, the actual release is straight forward: 30 | 31 | * First, ensure that you have installed your projects dependencies: 32 | 33 | ```sh 34 | pnpm install 35 | ``` 36 | 37 | * Second, ensure that you have obtained a 38 | [GitHub personal access token][generate-token] with the `repo` scope (no 39 | other permissions are needed). Make sure the token is available as the 40 | `GITHUB_AUTH` environment variable. 41 | 42 | For instance: 43 | 44 | ```bash 45 | export GITHUB_AUTH=abc123def456 46 | ``` 47 | 48 | [generate-token]: https://github.com/settings/tokens/new?scopes=repo&description=GITHUB_AUTH+env+variable 49 | 50 | * And last (but not least 😁) do your release. 51 | 52 | ```sh 53 | pnpm exec release-it 54 | ``` 55 | 56 | [release-it](https://github.com/release-it/release-it/) manages the actual 57 | release process. It will prompt you to to choose the version number after which 58 | you will have the chance to hand tweak the changelog to be used (for the 59 | `CHANGELOG.md` and GitHub release), then `release-it` continues on to tagging, 60 | pushing the tag and commits, etc. 61 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "20" 3 | pnpm = "10" 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glean-mcp-server", 3 | "version": "0.7.1", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "git+ssh://git@github.com/gleanwork/mcp-server.git" 8 | }, 9 | "workspaces": [ 10 | "packages/local-server" 11 | ], 12 | "scripts": { 13 | "build": "pnpm -r build", 14 | "test": "pnpm -r test", 15 | "lint": "npm-run-all --sequential lint:*", 16 | "lint:workspaces": "pnpm -r lint", 17 | "lint:package-json": "sort-package-json", 18 | "format": "pnpm -r format", 19 | "release": "pnpm -r release && release-it" 20 | }, 21 | "devDependencies": { 22 | "npm-run-all": "^4.1.5", 23 | "release-it": "^17.0.0", 24 | "sort-package-json": "^2.4.1" 25 | }, 26 | "pnpm": { 27 | "onlyBuiltDependencies": [ 28 | "esbuild", 29 | "msw" 30 | ] 31 | }, 32 | "release-it": { 33 | "npm": { 34 | "publish": false 35 | }, 36 | "git": { 37 | "tagName": "v${version}", 38 | "requireCleanWorkingDir": false 39 | }, 40 | "plugins": { 41 | "@release-it-plugins/lerna-changelog": { 42 | "infile": "CHANGELOG.md", 43 | "launchEditor": true 44 | } 45 | }, 46 | "github": { 47 | "release": true, 48 | "tokenRef": "GITHUB_AUTH" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/local-server/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 2 | import prettier from 'eslint-plugin-prettier'; 3 | import globals from 'globals'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | import path from 'node:path'; 6 | import { fileURLToPath } from 'node:url'; 7 | import js from '@eslint/js'; 8 | import { FlatCompat } from '@eslint/eslintrc'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }); 17 | 18 | export default [ 19 | { 20 | ignores: [ 21 | '**/.eslintrc.js', 22 | '**/prettier.config.js', 23 | '**/build', 24 | '**/node_modules', 25 | '**/package-log.json', 26 | ], 27 | }, 28 | ...compat.extends( 29 | 'eslint:recommended', 30 | 'plugin:@typescript-eslint/recommended', 31 | ), 32 | { 33 | plugins: { 34 | '@typescript-eslint': typescriptEslint, 35 | prettier, 36 | }, 37 | 38 | languageOptions: { 39 | globals: { 40 | ...globals.node, 41 | ...globals.mocha, 42 | }, 43 | 44 | parser: tsParser, 45 | ecmaVersion: 2019, 46 | sourceType: 'module', 47 | 48 | parserOptions: { 49 | project: './tsconfig.json', 50 | }, 51 | }, 52 | 53 | rules: { 54 | '@typescript-eslint/ban-ts-ignore': 'off', 55 | '@typescript-eslint/type-annotation-spacing': 'off', 56 | '@typescript-eslint/explicit-function-return-type': 'off', 57 | 'no-unused-vars': 'off', 58 | 59 | '@typescript-eslint/no-unused-vars': [ 60 | 'warn', 61 | { 62 | args: 'none', 63 | }, 64 | ], 65 | 66 | '@typescript-eslint/adjacent-overload-signatures': 'error', 67 | camelcase: 'off', 68 | '@typescript-eslint/consistent-type-assertions': 'error', 69 | 'no-array-constructor': 'off', 70 | '@typescript-eslint/no-array-constructor': 'error', 71 | 'no-empty': 'off', 72 | 'no-empty-function': 'off', 73 | '@typescript-eslint/explicit-module-boundary-types': 'off', 74 | '@typescript-eslint/no-empty-function': 'off', 75 | '@typescript-eslint/no-empty-interface': 'error', 76 | '@typescript-eslint/no-explicit-any': 'off', 77 | '@typescript-eslint/no-inferrable-types': 'error', 78 | '@typescript-eslint/no-misused-new': 'error', 79 | '@typescript-eslint/no-namespace': 'error', 80 | '@typescript-eslint/no-non-null-assertion': 'off', 81 | '@typescript-eslint/no-this-alias': 'error', 82 | 'no-use-before-define': 'off', 83 | '@typescript-eslint/no-use-before-define': 'off', 84 | '@typescript-eslint/no-var-requires': 'error', 85 | '@typescript-eslint/prefer-namespace-keyword': 'error', 86 | '@typescript-eslint/triple-slash-reference': 'error', 87 | '@typescript-eslint/no-floating-promises': ['error'], 88 | 'no-var': 'error', 89 | 'prefer-const': 'error', 90 | 'prefer-rest-params': 'error', 91 | 'prefer-spread': 'error', 92 | }, 93 | }, 94 | { 95 | files: ['**/*.js'], 96 | 97 | rules: { 98 | '@typescript-eslint/no-var-requires': 'off', 99 | }, 100 | }, 101 | ]; 102 | -------------------------------------------------------------------------------- /packages/local-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gleanwork/local-mcp-server", 3 | "version": "0.7.1", 4 | "description": "MCP server for Glean API integration", 5 | "keywords": [ 6 | "mcp", 7 | "mcp-server" 8 | ], 9 | "homepage": "", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/gleanwork/mcp-server.git", 13 | "directory": "packages/local-server" 14 | }, 15 | "license": "MIT", 16 | "author": "Steve Calvert ", 17 | "type": "module", 18 | "main": "./build/index.js", 19 | "bin": { 20 | "local-mcp-server": "./build/index.js", 21 | "mcp-server": "./build/index.js" 22 | }, 23 | "files": [ 24 | "build/**/*.js", 25 | "build/**/*.d.ts", 26 | "build/**/*.js.map", 27 | "build/**/*.d.ts.map", 28 | "!build/**/test/**", 29 | "!build/test/**", 30 | "!build/src/**", 31 | "!build/vitest.config.*" 32 | ], 33 | "scripts": { 34 | "build": "tsc", 35 | "format": "prettier --write \"src/**/*.ts\"", 36 | "inspector": "npx @modelcontextprotocol/inspector build/index.js", 37 | "lint": "npm-run-all --sequential lint:*", 38 | "lint:eslint": "eslint \"src/**/*.ts\" --fix", 39 | "lint:package-json": "sort-package-json", 40 | "lint:ts": "tsc --noEmit", 41 | "prepare": "pnpm run build", 42 | "release": "release-it", 43 | "test": "NODE_OPTIONS='--max-old-space-size=4096' vitest run", 44 | "test:all": "pnpm lint && pnpm lint:ts && pnpm test", 45 | "test:watch": "vitest", 46 | "watch": "tsc -w" 47 | }, 48 | "dependencies": { 49 | "@gleanwork/api-client": "0.4.4", 50 | "@modelcontextprotocol/sdk": "^1.7.0", 51 | "dotenv": "^16.3.1", 52 | "meow": "^13.2.0", 53 | "open": "^10.1.1", 54 | "tldts": "^7.0.7", 55 | "zod": "^3.24.2", 56 | "zod-to-json-schema": "^3.24.5" 57 | }, 58 | "devDependencies": { 59 | "@eslint/eslintrc": "^3.3.1", 60 | "@eslint/js": "^9.23.0", 61 | "@release-it-plugins/lerna-changelog": "^7.0.0", 62 | "@scalvert/bin-tester": "^2.1.1", 63 | "@types/node": "^22.14.0", 64 | "@types/node-fetch": "^2.6.12", 65 | "@typescript-eslint/eslint-plugin": "^8.27.0", 66 | "@typescript-eslint/parser": "^8.27.0", 67 | "console-test-helpers": "^0.3.3", 68 | "eslint": "^9.23.0", 69 | "eslint-plugin-prettier": "^5.2.5", 70 | "fixturify": "^3.0.0", 71 | "fs-extra": "^11.3.0", 72 | "globals": "^16.0.0", 73 | "msw": "^2.7.3", 74 | "npm-run-all": "^4.1.5", 75 | "prettier": "^3.5.3", 76 | "release-it": "^17.11.0", 77 | "rimraf": "^6.0.1", 78 | "sort-package-json": "^2.4.1", 79 | "typescript": "^5.8.2", 80 | "vitest": "^3.0.9" 81 | }, 82 | "engines": { 83 | "node": ">=20" 84 | }, 85 | "publishConfig": { 86 | "access": "public", 87 | "registry": "https://registry.npmjs.org" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /packages/local-server/src/auth/error.ts: -------------------------------------------------------------------------------- 1 | export enum AuthErrorCode { 2 | /** Unknown error */ 3 | Unknown = 'ERR_A_00', 4 | /** Using glean-token config for OAuth flow */ 5 | GleanTokenConfigUsedForOAuth = 'ERR_A_01', 6 | /** Network error fetching OAuth authorization server metadata */ 7 | AuthServerMetadataNetwork = 'ERR_A_02', 8 | /** Parse error in OAuth authorization server metadata */ 9 | AuthServerMetadataParse = 'ERR_A_03', 10 | /** Token endpoint missing in OAuth server metadata */ 11 | AuthServerMetadataMissingTokenEndpoint = 'ERR_A_04', 12 | /** Device authorization endpoint missing in OAuth server metadata */ 13 | AuthServerMetadataMissingDeviceEndpoint = 'ERR_A_05', 14 | /** Network error fetching OAuth protected resource metadata */ 15 | ProtectedResourceMetadataNetwork = 'ERR_A_06', 16 | /** Non-ok response fetching OAuth protected resource metadata */ 17 | ProtectedResourceMetadataNotOk = 'ERR_A_07', 18 | /** Parse error in OAuth protected resource metadata */ 19 | ProtectedResourceMetadataParse = 'ERR_A_08', 20 | /** Authorization servers missing in OAuth protected resource metadata */ 21 | ProtectedResourceMetadataMissingAuthServers = 'ERR_A_09', 22 | /** Device flow client id missing in OAuth protected resource metadata */ 23 | ProtectedResourceMetadataMissingClientId = 'ERR_A_10', 24 | /** Tried to refresh tokens with glean-token config */ 25 | GleanTokenConfigUsedForOAuthRefresh = 'ERR_A_11', 26 | /** No saved refresh token found */ 27 | RefreshTokenNotFound = 'ERR_A_12', 28 | /** Refresh token property missing */ 29 | RefreshTokenMissing = 'ERR_A_13', 30 | /** Unexpected response fetching access token */ 31 | UnexpectedAccessTokenResponse = 'ERR_A_14', 32 | /** Server error when fetching token */ 33 | FetchTokenServerError = 'ERR_A_15', 34 | /** Unexpected error requesting authorization grant */ 35 | UnexpectedAuthGrantError = 'ERR_A_16', 36 | /** Timed out waiting for OAuth device flow polling */ 37 | OAuthPollingTimeout = 'ERR_A_17', 38 | /** No interactive terminal for OAuth device authorization flow */ 39 | NoInteractiveTerminal = 'ERR_A_18', 40 | /** Invalid or missing Glean configuration */ 41 | InvalidConfig = 'ERR_A_19', 42 | /** Unexpected response fetching access token */ 43 | UnexpectedAuthGrantResponse = 'ERR_A_20', 44 | } 45 | 46 | /** 47 | * AuthError is an error that will be shown to the end user (with a message, no 48 | * stack trace). 49 | * 50 | * If AuthError is caught it should be re-thrown directly. 51 | */ 52 | export class AuthError extends Error { 53 | public code: AuthErrorCode; 54 | 55 | constructor( 56 | message: string, 57 | options: { code?: AuthErrorCode; cause?: unknown } = {}, 58 | ) { 59 | const code = options.code ?? AuthErrorCode.Unknown; 60 | const { cause } = options; 61 | super(`${code}: ${message}`, cause !== undefined ? { cause } : undefined); 62 | this.code = code; 63 | this.name = 'AuthError'; 64 | Error.captureStackTrace(this, this.constructor); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/local-server/src/auth/oauth-cache.ts: -------------------------------------------------------------------------------- 1 | import { getStateDir } from '../xdg/xdg.js'; 2 | import path from 'node:path'; 3 | import fs from 'node:fs'; 4 | import { GleanOAuthConfig } from '../config/config.js'; 5 | import { trace, error } from '../log/logger.js'; 6 | 7 | export function saveOAuthMetadata(config: GleanOAuthConfig) { 8 | const filePath = ensureOAuthMetadataCacheFilePath(); 9 | const payload = { 10 | ...config, 11 | timestamp: new Date(), 12 | }; 13 | fs.writeFileSync(filePath, JSON.stringify(payload, null, 2)); 14 | } 15 | 16 | export function loadOAuthMetadata() { 17 | const oauthMetadataCacheFile = buildOAuthMetadataCacheFilePath(); 18 | if (!fs.existsSync(oauthMetadataCacheFile)) { 19 | trace('No saved OAuth information'); 20 | return null; 21 | } 22 | 23 | try { 24 | const oauthMetadata = JSON.parse( 25 | fs.readFileSync(oauthMetadataCacheFile).toString(), 26 | ); 27 | if ( 28 | ![ 29 | 'baseUrl', 30 | 'issuer', 31 | 'clientId', 32 | 'authorizationEndpoint', 33 | 'tokenEndpoint', 34 | 'timestamp', 35 | ].every((k) => k in oauthMetadata) 36 | ) { 37 | error('Incomplete OAuth metadata file', oauthMetadata); 38 | return null; 39 | } 40 | const { 41 | baseUrl, 42 | issuer, 43 | clientId, 44 | clientSecret, 45 | authorizationEndpoint, 46 | tokenEndpoint, 47 | timestamp: timestampStr, 48 | } = oauthMetadata; 49 | 50 | const timestamp = new Date(Date.parse(timestampStr)); 51 | if (!isCacheFresh(timestamp)) { 52 | return null; 53 | } 54 | 55 | const oauthConfig: GleanOAuthConfig = { 56 | baseUrl, 57 | issuer, 58 | clientId, 59 | clientSecret, 60 | authorizationEndpoint, 61 | tokenEndpoint, 62 | authType: 'oauth', 63 | }; 64 | return oauthConfig; 65 | } catch (e) { 66 | // log & give up if the file has errors, e.g. 67 | // - not json 68 | // - missing fields 69 | trace('Error parsing oauth cache file', e); 70 | return null; 71 | } 72 | } 73 | 74 | function isCacheFresh(timestamp: Date) { 75 | // TODO: set a very short TTL; this is just a hack for now because of the way 76 | // the client & the rest of the system do getConfig(); can clean this up once 77 | // the sdk lands 78 | const SIX_HOURS_MS = 6 * 60 * 60 * 1000; 79 | const now = Date.now(); 80 | const timestampMs = timestamp.getTime(); 81 | 82 | return now - timestampMs < SIX_HOURS_MS; 83 | } 84 | 85 | function buildOAuthMetadataCacheFilePath() { 86 | const stateDir = getStateDir('glean'); 87 | const tokensFile = path.join(stateDir, 'oauth.json'); 88 | return tokensFile; 89 | } 90 | 91 | function ensureOAuthMetadataCacheFilePath() { 92 | const filePath = buildOAuthMetadataCacheFilePath(); 93 | 94 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); 95 | 96 | return filePath; 97 | } 98 | -------------------------------------------------------------------------------- /packages/local-server/src/auth/token-store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ensureFileExistsWithLimitedPermissions, 3 | getStateDir, 4 | } from '../xdg/xdg.js'; 5 | import path from 'node:path'; 6 | import { debug, trace } from '../log/logger.js'; 7 | import fs from 'node:fs'; 8 | import { TokenResponse } from './types'; 9 | 10 | export class Tokens { 11 | accessToken: string; 12 | refreshToken?: string; 13 | expiresAt?: Date; 14 | 15 | constructor({ 16 | accessToken, 17 | refreshToken, 18 | expiresAt, 19 | }: { 20 | accessToken: string; 21 | refreshToken?: string; 22 | expiresAt?: Date; 23 | }) { 24 | this.accessToken = accessToken; 25 | this.refreshToken = refreshToken; 26 | this.expiresAt = expiresAt; 27 | } 28 | 29 | static buildFromTokenResponse(tokenResponse: TokenResponse): Tokens { 30 | const { 31 | access_token: accessToken, 32 | refresh_token: refreshToken, 33 | expires_in: expiresIn, 34 | } = tokenResponse; 35 | 36 | // Calculate expiresAt by adding expires_in seconds to the current time 37 | let expiresAt: Date | undefined; 38 | if (expiresIn !== undefined) { 39 | expiresAt = new Date(); 40 | // MDN suggests settings seconds > 59 is invalid but the spec is fine with it and it works in v8 41 | // see 42 | expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); 43 | } 44 | 45 | return new Tokens({ 46 | accessToken, 47 | refreshToken, 48 | expiresAt: expiresAt, 49 | }); 50 | } 51 | 52 | isExpired(): boolean { 53 | if (!this.expiresAt) return false; 54 | // Add a 1 minute buffer 55 | const nowWithBuffer = new Date(Date.now() + 60 * 1000); 56 | return this.expiresAt <= nowWithBuffer; 57 | } 58 | } 59 | 60 | export function loadTokens(): Tokens | null { 61 | const tokensFile = buildTokensFilePath(); 62 | if (!fs.existsSync(tokensFile)) { 63 | debug('No saved tokens found.'); 64 | return null; 65 | } 66 | 67 | try { 68 | const tokensFileStr = fs.readFileSync(tokensFile, { encoding: 'utf-8' }); 69 | const tokensJson = JSON.parse(tokensFileStr); 70 | const { accessToken, refreshToken, expiresAt: expiresAtStr } = tokensJson; 71 | let expiresAt = undefined; 72 | if (expiresAtStr !== undefined) { 73 | expiresAt = new Date(Date.parse(expiresAtStr)); 74 | } 75 | 76 | debug('Loaded tokens'); 77 | return new Tokens({ 78 | accessToken, 79 | refreshToken, 80 | expiresAt, 81 | }); 82 | } catch (e) { 83 | trace(`error parsing tokens file: '${tokensFile}'`, e); 84 | return null; 85 | } 86 | } 87 | 88 | export function saveTokens(tokens: Tokens) { 89 | saveTokensToXDGState(tokens); 90 | } 91 | 92 | function buildTokensFilePath() { 93 | const stateDir = getStateDir('glean'); 94 | const tokensFile = path.join(stateDir, 'tokens.json'); 95 | return tokensFile; 96 | } 97 | 98 | function saveTokensToXDGState(tokens: Tokens) { 99 | const tokensFile = buildTokensFilePath(); 100 | ensureFileExistsWithLimitedPermissions(tokensFile); 101 | const tokensJson = JSON.stringify(tokens); 102 | fs.writeFileSync(tokensFile, tokensJson); 103 | 104 | trace('stored tokens'); 105 | } 106 | -------------------------------------------------------------------------------- /packages/local-server/src/auth/types.ts: -------------------------------------------------------------------------------- 1 | export interface TokenError { 2 | error: string; 3 | error_description: string; 4 | } 5 | 6 | export interface TokenResponse { 7 | token_type: 'Bearer'; 8 | access_token: string; 9 | scope?: string; 10 | /** 11 | * Seconds after which the access token expires and a new one is needed using 12 | * the one-time refresh token. 13 | */ 14 | expires_in?: number; 15 | refresh_token?: string; 16 | id_token?: string; 17 | } 18 | 19 | export interface AuthResponse { 20 | /** 21 | * Grant code we'll exchange for an access token 22 | */ 23 | device_code: string; 24 | /** 25 | * Code user has to enter at `verification_uri` 26 | */ 27 | user_code: string; 28 | /** 29 | * Where the user has to navigate to on a browser to enter `user_code`. 30 | */ 31 | verification_uri: string; 32 | /** 33 | * TTL of `device_code` 34 | */ 35 | expires_in: number; 36 | /** 37 | * How long we should wait between polls. 38 | */ 39 | interval: number; 40 | } 41 | 42 | export type AuthResponseWithURL = Omit & { 43 | verification_url: string; 44 | }; 45 | 46 | export function isTokenSuccess(json: any): json is TokenResponse { 47 | return ( 48 | json !== undefined && 49 | typeof json === 'object' && 50 | json?.token_type == 'Bearer' && 51 | 'access_token' in json 52 | ); 53 | } 54 | 55 | export function isAuthResponse(json: any): json is AuthResponse { 56 | return hasCommonAuthResponseFields(json) && 'verification_uri' in json; 57 | } 58 | export function isAuthResponseWithURL(json: any): json is AuthResponseWithURL { 59 | return hasCommonAuthResponseFields(json) && 'verification_url' in json; 60 | } 61 | 62 | function hasCommonAuthResponseFields(json: any): boolean { 63 | return ( 64 | json !== undefined && 65 | typeof json === 'object' && 66 | 'device_code' in json && 67 | 'user_code' in json && 68 | 'expires_in' in json && 69 | 'interval' in json 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /packages/local-server/src/common/client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Glean client implementation using the Glean API client. 3 | * 4 | * This module provides a client for interacting with the Glean API. 5 | * 6 | * Required environment variables: 7 | * - GLEAN_INSTANCE or GLEAN_SUBDOMAIN: Name of the Glean instance 8 | * - GLEAN_API_TOKEN: API token for authentication 9 | * 10 | * Optional environment variables: 11 | * - GLEAN_ACT_AS: User to impersonate (only valid with global tokens) 12 | * 13 | * @module common/client 14 | */ 15 | 16 | import { HTTPClient } from '@gleanwork/api-client/lib/http.js'; 17 | import { loadTokens } from '../auth/token-store.js'; 18 | import { 19 | getConfig, 20 | isGleanTokenConfig, 21 | isOAuthConfig, 22 | sanitizeConfig, 23 | } from '../config/config.js'; 24 | import { Glean, SDKOptions } from '@gleanwork/api-client'; 25 | import { Client } from '@gleanwork/api-client/sdk/client.js'; 26 | import { trace } from '../log/logger.js'; 27 | import { AuthError, AuthErrorCode } from '../auth/error.js'; 28 | import { ensureAuthTokenPresence } from '../auth/auth.js'; 29 | 30 | let clientInstancePromise: Promise | null = null; 31 | 32 | /** 33 | * Gets the singleton instance of the Glean client, creating it if necessary. 34 | * 35 | * @returns {Promise} The configured Glean client instance 36 | * @throws {Error} If required environment variables are missing 37 | */ 38 | export async function getClient(): Promise { 39 | if (!clientInstancePromise) { 40 | clientInstancePromise = (async () => { 41 | const glean = new Glean(await getAPIClientOptions()); 42 | return glean.client; 43 | })(); 44 | } 45 | return clientInstancePromise; 46 | } 47 | 48 | function buildHttpClientWithGlobalHeaders( 49 | headers: Record, 50 | ): HTTPClient { 51 | const httpClient = new HTTPClient(); 52 | 53 | httpClient.addHook('beforeRequest', (request) => { 54 | const nextRequest = new Request(request, { 55 | signal: request.signal || AbortSignal.timeout(5000), 56 | }); 57 | for (const [key, value] of Object.entries(headers)) { 58 | nextRequest.headers.set(key, value); 59 | } 60 | return nextRequest; 61 | }); 62 | 63 | return httpClient; 64 | } 65 | 66 | export async function getAPIClientOptions(): Promise { 67 | const config = await getConfig({ discoverOAuth: true }); 68 | const opts: SDKOptions = {}; 69 | 70 | opts.serverURL = config.baseUrl; 71 | 72 | trace('initializing client', opts.serverURL); 73 | 74 | if (isOAuthConfig(config)) { 75 | if (!(await ensureAuthTokenPresence())) { 76 | throw new AuthError( 77 | 'No OAuth tokens found. Please run `npx @gleanwork/mcp-server auth` to authenticate.', 78 | { code: AuthErrorCode.InvalidConfig }, 79 | ); 80 | } 81 | 82 | const tokens = loadTokens(); 83 | if (tokens === null) { 84 | throw new AuthError( 85 | 'No OAuth tokens found. Please run `npx @gleanwork/mcp-server auth` to authenticate.', 86 | { code: AuthErrorCode.InvalidConfig }, 87 | ); 88 | } 89 | opts.apiToken = tokens?.accessToken; 90 | opts.httpClient = buildHttpClientWithGlobalHeaders({ 91 | 'X-Glean-Auth-Type': 'OAUTH', 92 | }); 93 | } else if (isGleanTokenConfig(config)) { 94 | opts.apiToken = config.token; 95 | 96 | const { actAs } = config; 97 | if (actAs) { 98 | opts.httpClient = buildHttpClientWithGlobalHeaders({ 99 | 'X-Glean-Act-As': actAs, 100 | }); 101 | } 102 | } else { 103 | trace( 104 | 'Unexpected code; getConfig() should have errored or returned a valid config by now', 105 | sanitizeConfig(config), 106 | ); 107 | throw new AuthError( 108 | 'Missing or invalid Glean configuration. Please check that your environment variables are set correctly (e.g. GLEAN_INSTANCE or GLEAN_SUBDOMAIN).', 109 | { code: AuthErrorCode.InvalidConfig }, 110 | ); 111 | } 112 | 113 | return opts; 114 | } 115 | 116 | /** 117 | * Resets the client instance. Useful for testing or reconfiguration. 118 | */ 119 | export function resetClient(): void { 120 | clientInstancePromise = null; 121 | } 122 | -------------------------------------------------------------------------------- /packages/local-server/src/common/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Error handling utilities for the Glean MCP server. 3 | * 4 | * This module provides custom error types and type guards for handling 5 | * Glean-specific errors in a consistent way across the codebase. 6 | * 7 | * @module common/errors 8 | */ 9 | 10 | /** 11 | * Custom error class for Glean-specific errors. 12 | * Includes additional context about the error such as HTTP status and response data. 13 | * 14 | * @extends {Error} 15 | */ 16 | export class GleanError extends Error { 17 | constructor( 18 | message: string, 19 | public readonly status: number, 20 | public readonly response: unknown, 21 | ) { 22 | super(message); 23 | this.name = 'GleanError'; 24 | } 25 | } 26 | 27 | /** 28 | * Error class for invalid request errors (HTTP 400). 29 | * 30 | * @extends {GleanError} 31 | */ 32 | export class GleanInvalidRequestError extends GleanError { 33 | constructor( 34 | message = 'Invalid request', 35 | response: unknown = { message: 'Invalid request' }, 36 | ) { 37 | super(message, 400, response); 38 | this.name = 'GleanInvalidRequestError'; 39 | } 40 | } 41 | 42 | /** 43 | * Error class for authentication errors (HTTP 401). 44 | * 45 | * @extends {GleanError} 46 | */ 47 | export class GleanAuthenticationError extends GleanError { 48 | constructor( 49 | message = 'Authentication failed', 50 | response: unknown = { message: 'Authentication failed' }, 51 | ) { 52 | super(message, 401, response); 53 | this.name = 'GleanAuthenticationError'; 54 | } 55 | } 56 | 57 | /** 58 | * Error class for permission errors (HTTP 403). 59 | * 60 | * @extends {GleanError} 61 | */ 62 | export class GleanPermissionError extends GleanError { 63 | constructor( 64 | message = 'Forbidden', 65 | response: unknown = { message: 'Forbidden' }, 66 | ) { 67 | super(message, 403, response); 68 | this.name = 'GleanPermissionError'; 69 | } 70 | } 71 | 72 | /** 73 | * Error class for request timeout errors (HTTP 408). 74 | * 75 | * @extends {GleanError} 76 | */ 77 | export class GleanRequestTimeoutError extends GleanError { 78 | constructor( 79 | message = 'Request timeout', 80 | response: unknown = { message: 'Request timeout' }, 81 | ) { 82 | super(message, 408, response); 83 | this.name = 'GleanRequestTimeoutError'; 84 | } 85 | } 86 | 87 | /** 88 | * Error class for validation errors (HTTP 422). 89 | * 90 | * @extends {GleanError} 91 | */ 92 | export class GleanValidationError extends GleanError { 93 | constructor( 94 | message = 'Invalid query', 95 | response: unknown = { message: 'Invalid query' }, 96 | ) { 97 | super(message, 422, response); 98 | this.name = 'GleanValidationError'; 99 | } 100 | } 101 | 102 | /** 103 | * Error class for rate limit errors (HTTP 429). 104 | * 105 | * @extends {GleanError} 106 | */ 107 | export class GleanRateLimitError extends GleanError { 108 | constructor( 109 | message = 'Too many requests', 110 | public readonly resetAt: Date = new Date(Date.now() + 60000), 111 | response: unknown = { 112 | message: 'Too many requests', 113 | reset_at: new Date(Date.now() + 60000).toISOString(), 114 | }, 115 | ) { 116 | super(message, 429, response); 117 | this.name = 'GleanRateLimitError'; 118 | } 119 | } 120 | 121 | /** 122 | * Type guard to check if an error is a GleanError. 123 | * 124 | * @param {unknown} error - The error to check 125 | * @returns {boolean} True if the error is a GleanError 126 | */ 127 | export function isGleanError(error: unknown): error is GleanError { 128 | return error instanceof GleanError; 129 | } 130 | 131 | /** 132 | * Creates a specific GleanError subclass based on the HTTP status code. 133 | * 134 | * @param {number} status - The HTTP status code 135 | * @param {any} response - The response data 136 | * @returns {GleanError} The appropriate GleanError subclass 137 | */ 138 | export function createGleanError(status: number, response: any): GleanError { 139 | switch (status) { 140 | case 400: 141 | return new GleanInvalidRequestError(response?.message, response); 142 | case 401: 143 | return new GleanAuthenticationError(response?.message, response); 144 | case 403: 145 | return new GleanPermissionError(response?.message, response); 146 | case 408: 147 | return new GleanRequestTimeoutError(response?.message, response); 148 | case 422: 149 | return new GleanValidationError(response?.message, response); 150 | case 429: 151 | return new GleanRateLimitError( 152 | response?.message, 153 | new Date(response?.reset_at || Date.now() + 60000), 154 | response, 155 | ); 156 | default: 157 | return new GleanError( 158 | response?.message || 'Glean API error', 159 | status, 160 | response, 161 | ); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /packages/local-server/src/common/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Version information for the Glean MCP Server 3 | * Automatically synchronized with the version in package.json 4 | */ 5 | 6 | import fs from 'fs'; 7 | import path from 'path'; 8 | import { fileURLToPath } from 'url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const packageJsonPath = path.join(__dirname, '..', '..', 'package.json'); 13 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); 14 | 15 | export const VERSION = packageJson.version; 16 | -------------------------------------------------------------------------------- /packages/local-server/src/config/config.ts: -------------------------------------------------------------------------------- 1 | import { attemptUpgradeConfigToOAuth } from '../auth/auth.js'; 2 | import { loadOAuthMetadata } from '../auth/oauth-cache.js'; 3 | import { stripUndefined } from '../util/object.js'; 4 | 5 | export interface GleanConfigTokenAccess { 6 | authType: 'token'; 7 | token: string; 8 | actAs?: string; 9 | } 10 | 11 | export interface GleanConfigOAuthAccess { 12 | authType: 'oauth'; 13 | issuer: string; 14 | clientId: string; 15 | /** 16 | * Client secret for the device flow OAuth client. 17 | * 18 | * Note this is not actually a secret and does not secure anything. It 19 | * should be thought of as an extension of the client identifier. 20 | * 21 | * It's not recommended to even use client secrets for public OAuth clients, 22 | * but some providers require its use even for clients that cannot keep 23 | * secrets. 24 | */ 25 | clientSecret?: string; 26 | authorizationEndpoint: string; 27 | tokenEndpoint: string; 28 | } 29 | 30 | /** 31 | * Config where the user has only specified the baseUrl. In order to 32 | * authenticate we'd have to upgrade to a GleanOAuthConfig by querying the 33 | * resource's metadata. 34 | */ 35 | export interface GleanBasicConfigNoToken { 36 | authType: 'unknown'; 37 | issuer?: string; 38 | clientId?: string; 39 | /** 40 | * Client secret for the device flow OAuth client. 41 | * 42 | * Note this is not actually a secret and does not secure anything. It 43 | * should be thought of as an extension of the client identifier. 44 | * 45 | * It's not recommended to even use client secrets for public OAuth clients, 46 | * but some providers require its use even for clients that cannot keep 47 | * secrets. 48 | */ 49 | clientSecret?: string; 50 | authorizationEndpoint?: string; 51 | tokenEndpoint?: string; 52 | } 53 | 54 | interface GleanCommonConfig { 55 | baseUrl: string; 56 | } 57 | 58 | export type GleanTokenConfig = GleanCommonConfig & GleanConfigTokenAccess; 59 | export type GleanOAuthConfig = GleanCommonConfig & GleanConfigOAuthAccess; 60 | export type GleanBasicConfig = GleanCommonConfig & GleanBasicConfigNoToken; 61 | 62 | /** 63 | * Configuration interface for Glean client initialization. 64 | */ 65 | export type GleanConfig = 66 | | GleanBasicConfig 67 | | GleanTokenConfig 68 | | GleanOAuthConfig; 69 | 70 | /** 71 | * Type guard to check if a GleanConfig uses token authentication 72 | */ 73 | export function isGleanTokenConfig( 74 | config: GleanConfig, 75 | ): config is GleanConfig & GleanConfigTokenAccess { 76 | return (config as GleanConfigTokenAccess).authType === 'token'; 77 | } 78 | 79 | /** 80 | * Type guard to check if a GleanConfig uses OAuth authentication 81 | */ 82 | export function isOAuthConfig( 83 | config: GleanConfig, 84 | ): config is GleanConfig & GleanConfigOAuthAccess { 85 | return (config as GleanConfigOAuthAccess).authType === 'oauth'; 86 | } 87 | 88 | /** 89 | * Type guard to check if a GleanConfig uses OAuth authentication 90 | */ 91 | export function isBasicConfig( 92 | config: GleanConfig, 93 | ): config is GleanConfig & GleanBasicConfig { 94 | return (config as GleanBasicConfig).authType === 'unknown'; 95 | } 96 | 97 | /** 98 | * Type that represents the return value of getConfig based on the discoverOAuth option. 99 | * When discoverOAuth is true, the return type cannot be GleanBasicConfig. 100 | */ 101 | type GetConfigReturn = 102 | T['discoverOAuth'] extends true 103 | ? GleanTokenConfig | GleanOAuthConfig 104 | : GleanConfig; 105 | 106 | interface GetConfigOptions { 107 | discoverOAuth?: boolean; 108 | } 109 | 110 | /** 111 | * Validates required environment variables and returns client configuration. 112 | * 113 | * @param opts - Configuration options 114 | * @param opts.discoverOAuth - If true, attempts to discover OAuth 115 | * configuration via network calls to load oauth protected resource metadata. 116 | * Guarantees the return type is not a GleanBasicConfig 117 | * @returns A promise that resolves to: 118 | * - GleanTokenConfig | GleanOAuthConfig if discoverOAuth is true 119 | * - GleanConfig (which may include GleanBasicConfig) if discoverOAuth is false 120 | * @throws {Error} If required environment variables are missing 121 | */ 122 | export async function getConfig( 123 | opts?: T, 124 | ): Promise> { 125 | const config = getLocalConfig(); 126 | 127 | if (opts?.discoverOAuth === true && !isOAuthConfig(config)) { 128 | return attemptUpgradeConfigToOAuth(config); 129 | } else { 130 | // It's probably possible to avoid this cast with some type guards, but 131 | // it's annoying. 132 | return config as GetConfigReturn; 133 | } 134 | } 135 | 136 | function getLocalConfig(): GleanConfig { 137 | const instance = process.env.GLEAN_INSTANCE || process.env.GLEAN_SUBDOMAIN; 138 | const baseUrl = process.env.GLEAN_BASE_URL; 139 | const token = process.env.GLEAN_API_TOKEN; 140 | const actAs = process.env.GLEAN_ACT_AS; 141 | const issuer = process.env.GLEAN_OAUTH_ISSUER; 142 | const clientId = process.env.GLEAN_OAUTH_CLIENT_ID; 143 | const clientSecret = process.env.GLEAN_OAUTH_CLIENT_SECRET; 144 | const authorizationEndpoint = process.env.GLEAN_OAUTH_AUTHORIZATION_ENDPOINT; 145 | const tokenEndpoint = process.env.GLEAN_OAUTH_TOKEN_ENDPOINT; 146 | 147 | if (token !== undefined && (issuer !== undefined || clientId !== undefined)) { 148 | throw new Error( 149 | `Specify either GLEAN_OAUTH_ISSUER and GLEAN_OAUTH_CLIENT_ID or GLEAN_API_TOKEN, but not both.`, 150 | ); 151 | } 152 | 153 | if (token !== undefined) { 154 | return buildTokenConfig({ 155 | token, 156 | instance, 157 | baseUrl, 158 | actAs, 159 | }); 160 | } 161 | 162 | let config: GleanConfig = buildBasicConfig({ 163 | instance, 164 | baseUrl, 165 | issuer, 166 | clientId, 167 | clientSecret, 168 | authorizationEndpoint, 169 | tokenEndpoint, 170 | }); 171 | 172 | config = { 173 | ...stripUndefined(config), 174 | baseUrl: config.baseUrl, 175 | authType: config.authType, 176 | }; 177 | 178 | const oauthConfig = loadOAuthMetadata(); 179 | if (oauthConfig !== null) { 180 | // We have a saved OAuth config that's recent. No need to discover 181 | // anything, but let the user override individual things, mostly for 182 | // testing/debugging. 183 | const result: GleanOAuthConfig = { 184 | ...oauthConfig, 185 | ...config, 186 | authType: 'oauth', 187 | }; 188 | 189 | if ('clientSecret' in result && result.clientSecret === undefined) { 190 | delete result['clientSecret']; 191 | } 192 | 193 | return result; 194 | } 195 | 196 | // No saved OAuth config, just returrn a basic config and try to discover 197 | // OAuth. 198 | return config; 199 | } 200 | 201 | // SafeConfig is a partial record of all possible non-sensitive keys from GleanConfig, except 'token'. 202 | type SafeConfig = Partial< 203 | Record< 204 | Exclude< 205 | keyof GleanBasicConfig | keyof GleanTokenConfig | keyof GleanOAuthConfig, 206 | 'token' 207 | >, 208 | unknown 209 | > 210 | >; 211 | 212 | export function sanitizeConfig(config: GleanConfig): SafeConfig { 213 | const result = { ...config } as any; 214 | 215 | if ('token' in result) { 216 | delete result.token; 217 | } 218 | 219 | return result; 220 | } 221 | 222 | function buildGleanBaseUrl({ 223 | baseUrl, 224 | instance, 225 | }: { 226 | baseUrl?: string; 227 | instance?: string; 228 | }): string { 229 | if (!baseUrl) { 230 | if (!instance) { 231 | throw new Error('GLEAN_INSTANCE environment variable is required'); 232 | } 233 | return `https://${instance}-be.glean.com/`; 234 | } 235 | 236 | return baseUrl; 237 | } 238 | 239 | function buildBasicConfig({ 240 | instance, 241 | baseUrl, 242 | issuer, 243 | clientId, 244 | clientSecret, 245 | authorizationEndpoint, 246 | tokenEndpoint, 247 | }: { 248 | instance?: string; 249 | baseUrl?: string; 250 | issuer?: string; 251 | clientId?: string; 252 | clientSecret?: string; 253 | authorizationEndpoint?: string; 254 | tokenEndpoint?: string; 255 | }): GleanBasicConfig { 256 | return { 257 | authType: 'unknown', 258 | baseUrl: buildGleanBaseUrl({ instance, baseUrl }), 259 | issuer, 260 | clientId, 261 | clientSecret, 262 | authorizationEndpoint, 263 | tokenEndpoint, 264 | }; 265 | } 266 | 267 | function buildTokenConfig({ 268 | token, 269 | actAs, 270 | baseUrl, 271 | instance, 272 | }: { 273 | token: string; 274 | actAs?: string; 275 | baseUrl?: string; 276 | instance?: string; 277 | }): GleanConfig { 278 | if (!token) { 279 | throw new Error('GLEAN_API_TOKEN environment variable is required'); 280 | } 281 | 282 | return { 283 | authType: 'token', 284 | baseUrl: buildGleanBaseUrl({ instance, baseUrl }), 285 | token, 286 | ...(actAs ? { actAs } : {}), 287 | }; 288 | } 289 | -------------------------------------------------------------------------------- /packages/local-server/src/configure/client/claude.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Claude Desktop MCP Client Implementation 3 | * 4 | * https://modelcontextprotocol.io/quickstart/user 5 | */ 6 | 7 | import path from 'path'; 8 | import { MCPConfigPath, createBaseClient } from '../index.js'; 9 | 10 | export const claudeConfigPath: MCPConfigPath = { 11 | configDir: 'Claude', 12 | configFileName: 'claude_desktop_config.json', 13 | }; 14 | 15 | // Custom path resolver for Claude Desktop 16 | function claudePathResolver(homedir: string) { 17 | let baseDir: string; 18 | 19 | if (process.env.GLEAN_MCP_CONFIG_DIR) { 20 | baseDir = process.env.GLEAN_MCP_CONFIG_DIR; 21 | } else if (process.platform === 'darwin') { 22 | baseDir = path.join(homedir, 'Library', 'Application Support'); 23 | } else if (process.platform === 'win32') { 24 | baseDir = process.env.APPDATA || ''; 25 | } else { 26 | throw new Error('Unsupported platform for Claude Desktop'); 27 | } 28 | 29 | return path.join( 30 | baseDir, 31 | claudeConfigPath.configDir, 32 | claudeConfigPath.configFileName, 33 | ); 34 | } 35 | 36 | /** 37 | * Claude Desktop client configuration 38 | */ 39 | const claudeClient = createBaseClient( 40 | 'Claude Desktop', 41 | claudeConfigPath, 42 | [ 43 | 'Restart Claude Desktop', 44 | 'You should see a hammer icon in the input box, indicating MCP tools are available', 45 | 'Click the hammer to see available tools including Glean search and chat', 46 | ], 47 | claudePathResolver, 48 | ); 49 | 50 | export default claudeClient; 51 | -------------------------------------------------------------------------------- /packages/local-server/src/configure/client/cursor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cursor MCP Client Implementation 3 | * 4 | * https://docs.cursor.com/context/model-context-protocol 5 | */ 6 | 7 | import { MCPConfigPath, createBaseClient } from '../index.js'; 8 | 9 | export const cursorConfigPath: MCPConfigPath = { 10 | configDir: '.cursor', 11 | configFileName: 'mcp.json', 12 | }; 13 | 14 | /** 15 | * Cursor client configuration 16 | */ 17 | const cursorClient = createBaseClient('Cursor', cursorConfigPath, [ 18 | 'Restart Cursor', 19 | 'Agent will now have access to Glean search and chat tools', 20 | "You'll be asked for approval when Agent uses these tools", 21 | ]); 22 | 23 | export default cursorClient; 24 | -------------------------------------------------------------------------------- /packages/local-server/src/configure/client/vscode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * VS Code MCP Client Implementation 3 | * 4 | * https://code.visualstudio.com/docs/copilot/chat/mcp-servers 5 | */ 6 | 7 | import path from 'path'; 8 | import fs from 'fs'; 9 | import { 10 | MCPConfigPath, 11 | createBaseClient, 12 | VSCodeGlobalConfig, 13 | VSCodeWorkspaceConfig, 14 | MCPConfig, 15 | ConfigFileContents, 16 | } from '../index.js'; 17 | import type { ConfigureOptions } from '../../configure.js'; 18 | 19 | // VS Code user settings location varies by platform 20 | function getVSCodeUserSettingsPath(homedir: string): string { 21 | // Windows: %APPDATA%\Code\User\settings.json 22 | // macOS: ~/Library/Application Support/Code/User/settings.json 23 | // Linux: ~/.config/Code/User/settings.json 24 | const platform = process.platform; 25 | 26 | if (platform === 'win32') { 27 | return path.join( 28 | process.env.APPDATA || '', 29 | 'Code', 30 | 'User', 31 | 'settings.json', 32 | ); 33 | } else if (platform === 'darwin') { 34 | return path.join( 35 | homedir, 36 | 'Library', 37 | 'Application Support', 38 | 'Code', 39 | 'User', 40 | 'settings.json', 41 | ); 42 | } else { 43 | // Linux or other platforms 44 | return path.join(homedir, '.config', 'Code', 'User', 'settings.json'); 45 | } 46 | } 47 | 48 | /** 49 | * Creates VS Code workspace configuration format 50 | */ 51 | function createVSCodeWorkspaceConfig( 52 | instanceOrUrl?: string, 53 | apiToken?: string, 54 | ): VSCodeWorkspaceConfig { 55 | const env: Record = {}; 56 | 57 | if ( 58 | instanceOrUrl?.startsWith('http://') || 59 | instanceOrUrl?.startsWith('https://') 60 | ) { 61 | const baseUrl = instanceOrUrl.endsWith('/rest/api/v1') 62 | ? instanceOrUrl 63 | : `${instanceOrUrl}/rest/api/v1`; 64 | env.GLEAN_BASE_URL = baseUrl; 65 | } else if (instanceOrUrl) { 66 | env.GLEAN_INSTANCE = instanceOrUrl; 67 | } 68 | 69 | if (apiToken) { 70 | env.GLEAN_API_TOKEN = apiToken; 71 | } 72 | 73 | return { 74 | servers: { 75 | glean: { 76 | type: 'stdio', 77 | command: 'npx', 78 | args: ['-y', '@gleanwork/mcp-server'], 79 | env, 80 | }, 81 | }, 82 | }; 83 | } 84 | 85 | export const vscodeConfigPath: MCPConfigPath = { 86 | configDir: '', 87 | configFileName: '', 88 | }; 89 | 90 | const vscodeClient = createBaseClient('VS Code', vscodeConfigPath, [ 91 | 'Enable MCP support in VS Code by adding "chat.mcp.enabled": true to your user settings', 92 | 'Restart VS Code', 93 | 'Open the Chat view (Ctrl+Alt+I or ⌃⌘I) and select "Agent" mode from the dropdown', 94 | 'Click the "Tools" button to see and use Glean tools in Agent mode', 95 | "You'll be asked for approval when Agent uses these tools", 96 | ]); 97 | 98 | // Override configFilePath to handle workspace vs global 99 | vscodeClient.configFilePath = (homedir: string, options?: ConfigureOptions) => { 100 | if (options?.workspace) { 101 | return path.join(process.cwd(), '.vscode', 'mcp.json'); 102 | } 103 | return getVSCodeUserSettingsPath(homedir); 104 | }; 105 | 106 | // Override configTemplate to handle workspace vs global format 107 | vscodeClient.configTemplate = ( 108 | instanceOrUrl?: string, 109 | apiToken?: string, 110 | options?: ConfigureOptions, 111 | ): MCPConfig => { 112 | if (options?.workspace) { 113 | return createVSCodeWorkspaceConfig(instanceOrUrl, apiToken); 114 | } 115 | 116 | // Global configuration format 117 | const env: Record = {}; 118 | 119 | if ( 120 | instanceOrUrl?.startsWith('http://') || 121 | instanceOrUrl?.startsWith('https://') 122 | ) { 123 | const baseUrl = instanceOrUrl.endsWith('/rest/api/v1') 124 | ? instanceOrUrl 125 | : `${instanceOrUrl}/rest/api/v1`; 126 | env.GLEAN_BASE_URL = baseUrl; 127 | } else if (instanceOrUrl) { 128 | env.GLEAN_INSTANCE = instanceOrUrl; 129 | } 130 | 131 | if (apiToken) { 132 | env.GLEAN_API_TOKEN = apiToken; 133 | } 134 | 135 | return { 136 | mcp: { 137 | servers: { 138 | glean: { 139 | type: 'stdio', 140 | command: 'npx', 141 | args: ['-y', '@gleanwork/mcp-server'], 142 | env: env, 143 | }, 144 | }, 145 | }, 146 | }; 147 | }; 148 | 149 | // Override successMessage to handle workspace vs global 150 | vscodeClient.successMessage = ( 151 | configPath: string, 152 | options?: ConfigureOptions, 153 | ) => { 154 | if (options?.workspace) { 155 | return ` 156 | VS Code workspace MCP configuration has been configured: ${configPath} 157 | 158 | To use it: 159 | 1. Restart VS Code 160 | 2. Open the Chat view (⌃⌘I on Mac, Ctrl+Alt+I on Windows/Linux) and select "Agent" mode 161 | 3. Click the "Tools" button to see and use Glean tools in Agent mode 162 | 4. You'll be asked for approval when Agent uses these tools 163 | 164 | Notes: 165 | - This configuration is specific to this workspace 166 | - Configuration is at: ${configPath} 167 | `; 168 | } 169 | 170 | return ` 171 | VS Code MCP configuration has been configured in your user settings: ${configPath} 172 | 173 | To use it: 174 | 1. Enable MCP support in VS Code by adding "chat.mcp.enabled": true to your user settings 175 | 2. Restart VS Code 176 | 3. Open the Chat view (Ctrl+Alt+I or ⌃⌘I) and select "Agent" mode from the dropdown 177 | 4. Click the "Tools" button to see and use Glean tools in Agent mode 178 | 5. You'll be asked for approval when Agent uses these tools 179 | 180 | Notes: 181 | - You may need to set your Glean instance and API token if they weren't provided during configuration 182 | - User settings are at: ${configPath} 183 | `; 184 | }; 185 | 186 | // Override hasExistingConfig to handle workspace vs global format 187 | vscodeClient.hasExistingConfig = ( 188 | existingConfig: ConfigFileContents, 189 | options?: ConfigureOptions, 190 | ) => { 191 | if (options?.workspace) { 192 | const workspaceConfig = existingConfig as VSCodeWorkspaceConfig; 193 | return ( 194 | workspaceConfig.servers?.glean?.command === 'npx' && 195 | workspaceConfig.servers?.glean?.args?.includes('@gleanwork/mcp-server') 196 | ); 197 | } 198 | 199 | const globalConfig = existingConfig as VSCodeGlobalConfig; 200 | return ( 201 | globalConfig.mcp?.servers?.glean?.command === 'npx' && 202 | globalConfig.mcp?.servers?.glean?.args?.includes('@gleanwork/mcp-server') 203 | ); 204 | }; 205 | 206 | // Override updateConfig to handle workspace vs global format 207 | vscodeClient.updateConfig = ( 208 | existingConfig: ConfigFileContents, 209 | newConfig: MCPConfig, 210 | options?: ConfigureOptions, 211 | ): ConfigFileContents => { 212 | if (options?.workspace) { 213 | const workspaceNewConfig = newConfig as VSCodeWorkspaceConfig; 214 | const result = { ...existingConfig } as ConfigFileContents & 215 | VSCodeWorkspaceConfig; 216 | result.servers = result.servers || {}; 217 | result.servers.glean = workspaceNewConfig.servers.glean; 218 | return result; 219 | } 220 | 221 | const globalNewConfig = newConfig as VSCodeGlobalConfig; 222 | const result = { ...existingConfig } as ConfigFileContents & 223 | VSCodeGlobalConfig; 224 | result.mcp = result.mcp || { servers: {} }; 225 | result.mcp.servers = result.mcp.servers || {}; 226 | result.mcp.servers.glean = globalNewConfig.mcp.servers.glean; 227 | return result; 228 | }; 229 | 230 | export default vscodeClient; 231 | -------------------------------------------------------------------------------- /packages/local-server/src/configure/client/windsurf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Windsurf MCP Client Implementation 3 | * 4 | * https://docs.windsurf.com/windsurf/mcp 5 | */ 6 | 7 | import path from 'path'; 8 | import { MCPConfigPath, createBaseClient } from '../index.js'; 9 | 10 | export const windsurfConfigPath: MCPConfigPath = { 11 | configDir: path.join('.codeium', 'windsurf'), 12 | configFileName: 'mcp_config.json', 13 | }; 14 | 15 | /** 16 | * Windsurf client configuration 17 | */ 18 | const windsurfClient = createBaseClient('Windsurf', windsurfConfigPath, [ 19 | 'Open Windsurf Settings > Advanced Settings', 20 | 'Scroll to the Cascade section', 21 | 'Press the refresh button after configuration', 22 | 'You should now see Glean in your available MCP servers', 23 | ]); 24 | 25 | export default windsurfClient; 26 | -------------------------------------------------------------------------------- /packages/local-server/src/configure/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Client module for MCP Clients 3 | * 4 | * Common interfaces and types for MCP client implementations 5 | */ 6 | 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | import { fileURLToPath } from 'url'; 10 | import { VERSION } from '../common/version.js'; 11 | import type { ConfigureOptions } from '../configure.js'; 12 | 13 | export { VERSION }; 14 | 15 | export interface MCPConfigPath { 16 | configDir: string; 17 | configFileName: string; 18 | } 19 | 20 | /** 21 | * MCP Server configuration 22 | */ 23 | export interface MCPServerConfig { 24 | type?: string; 25 | command: string; 26 | args: Array; 27 | env: Record; 28 | } 29 | 30 | /** 31 | * Standard MCP configuration format (Claude, Cursor, Windsurf) 32 | */ 33 | export interface StandardMCPConfig { 34 | mcpServers: { 35 | glean: MCPServerConfig; 36 | [key: string]: MCPServerConfig; 37 | }; 38 | } 39 | 40 | /** 41 | * VS Code global configuration format 42 | */ 43 | export interface VSCodeGlobalConfig { 44 | mcp: { 45 | servers: { 46 | glean: MCPServerConfig; 47 | [key: string]: MCPServerConfig; 48 | }; 49 | }; 50 | [key: string]: unknown; 51 | } 52 | 53 | /** 54 | * VS Code workspace configuration format 55 | */ 56 | export interface VSCodeWorkspaceConfig { 57 | servers: { 58 | glean: MCPServerConfig; 59 | [key: string]: MCPServerConfig; 60 | }; 61 | [key: string]: unknown; 62 | } 63 | 64 | /** 65 | * Union of all possible MCP configuration formats 66 | */ 67 | export type MCPConfig = 68 | | StandardMCPConfig 69 | | VSCodeGlobalConfig 70 | | VSCodeWorkspaceConfig; 71 | 72 | /** 73 | * Generic config file contents that might contain MCP configuration 74 | * Represents the parsed contents of client config files like VS Code settings.json 75 | */ 76 | export type ConfigFileContents = Record & Partial; 77 | 78 | /** 79 | * Interface for MCP client configuration details 80 | */ 81 | export interface MCPClientConfig { 82 | /** Display name for the client */ 83 | displayName: string; 84 | 85 | /** 86 | * Path to the config file, supports OS-specific paths and client-specific options. 87 | * If GLEAN_MCP_CONFIG_DIR environment variable is set, it will override the default path. 88 | */ 89 | configFilePath: (homedir: string, options?: ConfigureOptions) => string; 90 | 91 | /** Function to generate the config JSON for this client */ 92 | configTemplate: ( 93 | subdomainOrUrl?: string, 94 | apiToken?: string, 95 | options?: ConfigureOptions, 96 | ) => MCPConfig; 97 | 98 | /** Instructions displayed after successful configuration */ 99 | successMessage: (configPath: string, options?: ConfigureOptions) => string; 100 | 101 | /** 102 | * Check if configuration exists in the existing config object 103 | * @param existingConfig Existing configuration object from the config file 104 | * @param options Additional options that may affect detection logic 105 | * @returns boolean indicating if configuration exists 106 | */ 107 | hasExistingConfig: ( 108 | existingConfig: ConfigFileContents, 109 | options?: ConfigureOptions, 110 | ) => boolean; 111 | 112 | /** 113 | * Update existing configuration with new config 114 | * @param existingConfig Existing configuration object to update 115 | * @param newConfig New configuration to merge with existing 116 | * @param options Additional options that may affect merging logic 117 | * @returns Updated configuration object 118 | */ 119 | updateConfig: ( 120 | existingConfig: ConfigFileContents, 121 | newConfig: MCPConfig, 122 | options?: ConfigureOptions, 123 | ) => ConfigFileContents; 124 | } 125 | 126 | /** 127 | * Creates a standard MCP server configuration template 128 | */ 129 | export function createConfigTemplate( 130 | instanceOrUrl = '', 131 | apiToken?: string, 132 | ): StandardMCPConfig { 133 | const env: Record = {}; 134 | 135 | // If it looks like a URL, use GLEAN_BASE_URL 136 | if ( 137 | instanceOrUrl.startsWith('http://') || 138 | instanceOrUrl.startsWith('https://') 139 | ) { 140 | const baseUrl = instanceOrUrl.endsWith('/rest/api/v1') 141 | ? instanceOrUrl 142 | : `${instanceOrUrl}/rest/api/v1`; 143 | env.GLEAN_BASE_URL = baseUrl; 144 | } else { 145 | env.GLEAN_INSTANCE = instanceOrUrl; 146 | } 147 | 148 | // Only include GLEAN_API_TOKEN if a token is provided 149 | if (apiToken) { 150 | env.GLEAN_API_TOKEN = apiToken; 151 | } 152 | 153 | return { 154 | mcpServers: { 155 | glean: { 156 | command: 'npx', 157 | args: ['-y', '@gleanwork/mcp-server'], 158 | env, 159 | }, 160 | }, 161 | }; 162 | } 163 | 164 | /** 165 | * Creates a standard file path resolver that respects GLEAN_MCP_CONFIG_DIR 166 | */ 167 | export function createStandardPathResolver(configPath: MCPConfigPath) { 168 | return (homedir: string) => { 169 | const baseDir = process.env.GLEAN_MCP_CONFIG_DIR || homedir; 170 | return path.join(baseDir, configPath.configDir, configPath.configFileName); 171 | }; 172 | } 173 | 174 | /** 175 | * Creates a success message with standardized format 176 | */ 177 | export function createSuccessMessage( 178 | clientName: string, 179 | configPath: string, 180 | instructions: string[], 181 | ) { 182 | return ` 183 | ${clientName} MCP configuration has been configured to: ${configPath} 184 | 185 | To use it: 186 | ${instructions.map((instr, i) => `${i + 1}. ${instr}`).join('\n')} 187 | 188 | Notes: 189 | - You may need to set your Glean instance and API token if they weren't provided during configuration 190 | - Configuration is at: ${configPath} 191 | `; 192 | } 193 | 194 | /** 195 | * Creates a base client configuration that can be extended 196 | */ 197 | export function createBaseClient( 198 | displayName: string, 199 | configPath: MCPConfigPath, 200 | instructions: string[], 201 | pathResolverOverride?: (homedir: string) => string, 202 | ): MCPClientConfig { 203 | return { 204 | displayName, 205 | 206 | configFilePath: 207 | pathResolverOverride || createStandardPathResolver(configPath), 208 | 209 | configTemplate: createConfigTemplate, 210 | 211 | successMessage: (configPath) => 212 | createSuccessMessage(displayName, configPath, instructions), 213 | 214 | hasExistingConfig: (existingConfig: ConfigFileContents) => { 215 | const standardConfig = existingConfig as StandardMCPConfig; 216 | return ( 217 | standardConfig.mcpServers?.glean?.command === 'npx' && 218 | standardConfig.mcpServers?.glean?.args?.includes( 219 | '@gleanwork/mcp-server', 220 | ) 221 | ); 222 | }, 223 | 224 | updateConfig: ( 225 | existingConfig: ConfigFileContents, 226 | newConfig: MCPConfig, 227 | ) => { 228 | const standardNewConfig = newConfig as StandardMCPConfig; 229 | const result = { ...existingConfig } as ConfigFileContents & 230 | StandardMCPConfig; 231 | result.mcpServers = result.mcpServers || {}; 232 | result.mcpServers.glean = standardNewConfig.mcpServers.glean; 233 | return result; 234 | }, 235 | }; 236 | } 237 | 238 | /** 239 | * Map of all available MCP clients 240 | * Will be populated dynamically by scanning the client directory 241 | */ 242 | export const availableClients: Record = {}; 243 | 244 | /** 245 | * Dynamically load all client modules in the client directory 246 | */ 247 | async function loadClientModules() { 248 | const __filename = fileURLToPath(import.meta.url); 249 | const __dirname = path.dirname(__filename); 250 | const clientDir = path.join(__dirname, 'client'); 251 | 252 | try { 253 | const files = fs.readdirSync(clientDir); 254 | 255 | const clientFiles = files.filter( 256 | (file) => file.endsWith('.js') && file !== 'index.js', 257 | ); 258 | 259 | for (const file of clientFiles) { 260 | const clientName = path.basename(file, '.js'); 261 | 262 | try { 263 | const clientModule = await import(`./client/${clientName}.js`); 264 | 265 | if (clientModule.default) { 266 | availableClients[clientName] = clientModule.default; 267 | } 268 | } catch (error) { 269 | console.error(`Error loading client module ${clientName}: ${error}`); 270 | } 271 | } 272 | } catch (error) { 273 | console.error(`Error loading client modules: ${error}`); 274 | } 275 | } 276 | 277 | /** 278 | * Ensures all client modules are loaded before using them 279 | * Returns a promise that resolves when loading is complete 280 | */ 281 | let clientsLoaded = false; 282 | let loadPromise: Promise | null = null; 283 | 284 | export async function ensureClientsLoaded(): Promise { 285 | if (clientsLoaded) { 286 | return Promise.resolve(); 287 | } 288 | 289 | if (!loadPromise) { 290 | loadPromise = loadClientModules().then(() => { 291 | clientsLoaded = true; 292 | }); 293 | } 294 | 295 | return loadPromise; 296 | } 297 | 298 | void ensureClientsLoaded().catch((error) => { 299 | console.error('Failed to load client modules:', error); 300 | }); 301 | -------------------------------------------------------------------------------- /packages/local-server/src/log/logger.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { 4 | ensureFileExistsWithLimitedPermissions, 5 | getStateDir as getXDGStateDir, 6 | } from '../xdg/xdg.js'; 7 | 8 | export enum LogLevel { 9 | TRACE = 0, 10 | DEBUG = 1, 11 | INFO = 2, 12 | WARN = 3, 13 | ERROR = 4, 14 | } 15 | 16 | /** 17 | * A simple logger that exposes functions for standard log levels (`trace, 18 | * `debug`, &c.) for a singleton logger. 19 | * 20 | * Logs are written to `$HOME/.local/state/glean/mcp.log` by default (or the 21 | * XDG equivalent for windows, or if XDG env vars are set). 22 | * 23 | * Logs are intended to be provided by users to help with troubleshooting. 24 | */ 25 | export class Logger { 26 | private static instance?: Logger; 27 | private logFilePath: string; 28 | private logLevel: LogLevel; 29 | 30 | constructor(appName = 'glean') { 31 | const logDir = getXDGStateDir(appName); 32 | 33 | // Ensure log directory exists 34 | if (!fs.existsSync(logDir)) { 35 | fs.mkdirSync(logDir, { recursive: true }); 36 | } 37 | 38 | this.logFilePath = path.join(logDir, 'mcp.log'); 39 | ensureFileExistsWithLimitedPermissions(this.logFilePath); 40 | this.logLevel = LogLevel.TRACE; // Default to most verbose logging 41 | } 42 | 43 | public static getInstance(): Logger { 44 | if (!Logger.instance) { 45 | Logger.instance = new Logger(); 46 | } 47 | return Logger.instance; 48 | } 49 | 50 | public static reset() { 51 | Logger.instance = undefined; 52 | } 53 | 54 | public setLogLevel(level: LogLevel): void { 55 | this.logLevel = level; 56 | } 57 | 58 | private log(level: LogLevel, ...args: any[]): void { 59 | if (level < this.logLevel) return; 60 | 61 | const timestamp = new Date().toISOString(); 62 | const levelName = LogLevel[level]; 63 | 64 | let message = ''; 65 | const dataObjects: Record[] = []; 66 | 67 | // Helper to format errors with causes 68 | function formatErrorWithCauses(err: Error, indent = 0): string { 69 | const pad = ' '.repeat(indent); 70 | let out = `${pad}[${err.name}: ${err.message}]`; 71 | if (err.stack) { 72 | // Only include stack for the top-level error 73 | if (indent === 0) { 74 | out += `\n${pad}${err.stack.replace(/\n/g, `\n${pad}`)}`; 75 | } 76 | } 77 | // Handle error cause (ES2022 standard, but may be polyfilled) 78 | const cause = (err as any).cause; 79 | if (cause instanceof Error) { 80 | out += `\n${pad}Caused by: ` + formatErrorWithCauses(cause, indent + 1); 81 | } else if (cause !== undefined) { 82 | out += `\n${pad}Caused by: ${JSON.stringify(cause)}`; 83 | } 84 | return out; 85 | } 86 | 87 | // Process each argument 88 | args.forEach((arg) => { 89 | if (typeof arg === 'string') { 90 | message += (message ? ' ' : '') + arg; 91 | } else if (arg instanceof Error) { 92 | message += (message ? ' ' : '') + formatErrorWithCauses(arg); 93 | } else if (arg !== null && typeof arg === 'object') { 94 | dataObjects.push(arg); 95 | } else if (arg !== undefined) { 96 | // Convert primitives to string 97 | message += (message ? ' ' : '') + String(arg); 98 | } 99 | }); 100 | 101 | let logMessage = `[${timestamp}] [${levelName}] ${message}`; 102 | 103 | // Add data objects if any 104 | if (dataObjects.length > 0) { 105 | dataObjects.forEach((data) => { 106 | logMessage += ` ${JSON.stringify(data)}`; 107 | }); 108 | } 109 | 110 | logMessage += '\n'; 111 | 112 | // Append to log file 113 | fs.appendFileSync(this.logFilePath, logMessage); 114 | } 115 | 116 | public trace(...args: any[]): void { 117 | this.log(LogLevel.TRACE, ...args); 118 | } 119 | 120 | public debug(...args: any[]): void { 121 | this.log(LogLevel.DEBUG, ...args); 122 | } 123 | 124 | public info(...args: any[]): void { 125 | this.log(LogLevel.INFO, ...args); 126 | } 127 | 128 | public warn(...args: any[]): void { 129 | this.log(LogLevel.WARN, ...args); 130 | } 131 | 132 | public error(...args: any[]): void { 133 | this.log(LogLevel.ERROR, ...args); 134 | } 135 | } 136 | 137 | // Exported functions 138 | export function trace(...args: any[]): void { 139 | Logger.getInstance().trace(...args); 140 | } 141 | 142 | export function debug(...args: any[]): void { 143 | Logger.getInstance().debug(...args); 144 | } 145 | 146 | export function info(...args: any[]): void { 147 | Logger.getInstance().info(...args); 148 | } 149 | 150 | export function warn(...args: any[]): void { 151 | Logger.getInstance().warn(...args); 152 | } 153 | 154 | export function error(...args: any[]): void { 155 | Logger.getInstance().error(...args); 156 | } 157 | -------------------------------------------------------------------------------- /packages/local-server/src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Glean Model Context Protocol (MCP) Server Implementation 3 | * 4 | * This server implements the Model Context Protocol, providing a standardized interface 5 | * for AI models to interact with Glean's search and chat capabilities. It uses stdio 6 | * for communication and implements the MCP specification for tool discovery and execution. 7 | * 8 | * The server provides two main tools: 9 | * 1. search - Allows searching through Glean's indexed content 10 | * 2. chat - Enables conversation with Glean's AI assistant 11 | */ 12 | 13 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 14 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 15 | import { 16 | CallToolRequestSchema, 17 | ListToolsRequestSchema, 18 | } from '@modelcontextprotocol/sdk/types.js'; 19 | import { z } from 'zod'; 20 | import { zodToJsonSchema } from 'zod-to-json-schema'; 21 | import * as search from './tools/search.js'; 22 | import * as chat from './tools/chat.js'; 23 | import * as peopleProfileSearch from './tools/people_profile_search.js'; 24 | import { 25 | isGleanError, 26 | GleanError, 27 | GleanInvalidRequestError, 28 | GleanAuthenticationError, 29 | GleanPermissionError, 30 | GleanRateLimitError, 31 | GleanRequestTimeoutError, 32 | GleanValidationError, 33 | } from './common/errors.js'; 34 | import { VERSION } from './common/version.js'; 35 | 36 | export const TOOL_NAMES = { 37 | companySearch: 'company_search', 38 | peopleProfileSearch: 'people_profile_search', 39 | chat: 'chat', 40 | }; 41 | 42 | /** 43 | * MCP server instance configured for Glean's implementation. 44 | * Supports tool discovery and execution through the MCP protocol. 45 | */ 46 | const server = new Server( 47 | { 48 | name: 'Glean Tools MCP', 49 | version: VERSION, 50 | }, 51 | { capabilities: { tools: {} } }, 52 | ); 53 | 54 | /** 55 | * Returns the list of available tools with descriptions & JSON Schemas. 56 | */ 57 | export async function listToolsHandler() { 58 | return { 59 | tools: [ 60 | { 61 | name: TOOL_NAMES.companySearch, 62 | description: `Find relevant company documents and data 63 | 64 | Example request: 65 | 66 | { 67 | "query": "What are the company holidays this year?", 68 | "datasources": ["drive", "confluence"] 69 | } 70 | `, 71 | inputSchema: zodToJsonSchema(search.ToolSearchSchema), 72 | }, 73 | { 74 | name: TOOL_NAMES.chat, 75 | description: `Chat with Glean Assistant using Glean's RAG 76 | 77 | Example request: 78 | 79 | { 80 | "message": "What are the company holidays this year?", 81 | "context": [ 82 | "Hello, I need some information about time off.", 83 | "I'm planning my vacation for next year." 84 | ] 85 | } 86 | `, 87 | inputSchema: zodToJsonSchema(chat.ToolChatSchema), 88 | }, 89 | { 90 | name: TOOL_NAMES.peopleProfileSearch, 91 | description: `Search for people profiles in the company 92 | 93 | Example request: 94 | 95 | { 96 | "query": "Find people named John Doe", 97 | "filters": { 98 | "department": "Engineering", 99 | "city": "San Francisco" 100 | }, 101 | "pageSize": 10 102 | } 103 | 104 | `, 105 | inputSchema: zodToJsonSchema( 106 | peopleProfileSearch.ToolPeopleProfileSearchSchema, 107 | ), 108 | }, 109 | ], 110 | }; 111 | } 112 | 113 | /** 114 | * Executes a tool based on the MCP callTool request. 115 | */ 116 | export async function callToolHandler( 117 | request: z.infer, 118 | ) { 119 | try { 120 | if (!request.params.arguments) { 121 | throw new Error('Arguments are required'); 122 | } 123 | 124 | switch (request.params.name) { 125 | case TOOL_NAMES.companySearch: { 126 | const args = search.ToolSearchSchema.parse(request.params.arguments); 127 | const result = await search.search(args); 128 | const formattedResults = search.formatResponse(result); 129 | 130 | return { 131 | content: [{ type: 'text', text: formattedResults }], 132 | isError: false, 133 | }; 134 | } 135 | 136 | case TOOL_NAMES.chat: { 137 | const args = chat.ToolChatSchema.parse(request.params.arguments); 138 | const result = await chat.chat(args); 139 | const formattedResults = chat.formatResponse(result); 140 | 141 | return { 142 | content: [{ type: 'text', text: formattedResults }], 143 | isError: false, 144 | }; 145 | } 146 | 147 | case TOOL_NAMES.peopleProfileSearch: { 148 | const args = peopleProfileSearch.ToolPeopleProfileSearchSchema.parse( 149 | request.params.arguments, 150 | ); 151 | const result = await peopleProfileSearch.peopleProfileSearch(args); 152 | const formattedResults = peopleProfileSearch.formatResponse(result); 153 | 154 | return { 155 | content: [{ type: 'text', text: formattedResults }], 156 | isError: false, 157 | }; 158 | } 159 | 160 | default: 161 | throw new Error(`Unknown tool: ${request.params.name}`); 162 | } 163 | } catch (error) { 164 | if (error instanceof z.ZodError) { 165 | const errorDetails = error.errors 166 | .map((err) => { 167 | return `${err.path.join('.')}: ${err.message}`; 168 | }) 169 | .join('\n'); 170 | 171 | return { 172 | content: [ 173 | { 174 | type: 'text', 175 | text: `Invalid input:\n${errorDetails}`, 176 | }, 177 | ], 178 | isError: true, 179 | }; 180 | } 181 | 182 | if (isGleanError(error)) { 183 | return { 184 | content: [{ type: 'text', text: formatGleanError(error) }], 185 | isError: true, 186 | }; 187 | } 188 | 189 | return { 190 | content: [ 191 | { 192 | type: 'text', 193 | text: `Error: ${ 194 | error instanceof Error ? error.message : String(error) 195 | }`, 196 | }, 197 | ], 198 | isError: true, 199 | }; 200 | } 201 | } 202 | 203 | server.setRequestHandler(ListToolsRequestSchema, listToolsHandler); 204 | server.setRequestHandler(CallToolRequestSchema, callToolHandler); 205 | 206 | /** 207 | * Formats a GleanError into a human-readable error message. 208 | * This function provides detailed error messages based on the specific error type. 209 | * 210 | * @param {GleanError} error - The error to format 211 | * @returns {string} A formatted error message 212 | */ 213 | export function formatGleanError(error: GleanError): string { 214 | let message = `Glean API Error: ${error.message}`; 215 | 216 | if (error instanceof GleanInvalidRequestError) { 217 | message = `Invalid Request: ${error.message}`; 218 | if (error.response) { 219 | message += `\nDetails: ${JSON.stringify(error.response)}`; 220 | } 221 | } else if (error instanceof GleanAuthenticationError) { 222 | message = `Authentication Failed: ${error.message}`; 223 | } else if (error instanceof GleanPermissionError) { 224 | message = `Permission Denied: ${error.message}`; 225 | } else if (error instanceof GleanRequestTimeoutError) { 226 | message = `Request Timeout: ${error.message}`; 227 | } else if (error instanceof GleanValidationError) { 228 | message = `Invalid Query: ${error.message}`; 229 | if (error.response) { 230 | message += `\nDetails: ${JSON.stringify(error.response)}`; 231 | } 232 | } else if (error instanceof GleanRateLimitError) { 233 | message = `Rate Limit Exceeded: ${error.message}`; 234 | message += `\nResets at: ${error.resetAt.toISOString()}`; 235 | } 236 | 237 | return message; 238 | } 239 | 240 | /** 241 | * Initializes and starts the MCP server using stdio transport. 242 | * This is the main entry point for the server process. 243 | * 244 | * @async 245 | * @param {Object} options - Options for server initialization 246 | * @param {string} [options.instance] - The Glean instance name from the command line 247 | * @param {string} [options.token] - The Glean API token from the command line 248 | * @throws {Error} If server initialization or connection fails 249 | */ 250 | export async function runServer(options?: { 251 | instance?: string; 252 | token?: string; 253 | }) { 254 | // Set environment variables from command line args if provided 255 | if (options?.instance) { 256 | process.env.GLEAN_INSTANCE = options.instance; 257 | } 258 | 259 | if (options?.token) { 260 | process.env.GLEAN_API_TOKEN = options.token; 261 | } 262 | 263 | const transport = new StdioServerTransport(); 264 | 265 | try { 266 | await server.connect(transport); 267 | console.error(`Glean MCP Server v${VERSION} running on stdio`); 268 | 269 | // Create a promise that never resolves to keep the process alive 270 | // This is necessary because the MCP server will handle requests 271 | // over the transport until terminated 272 | return new Promise(() => { 273 | // The server keeps running until the process is terminated 274 | console.error( 275 | 'Server is now handling requests. Press Ctrl+C to terminate.', 276 | ); 277 | 278 | // Handle shutdown signals 279 | process.on('SIGINT', () => { 280 | console.error('Received SIGINT signal. Shutting down...'); 281 | process.exit(0); 282 | }); 283 | 284 | process.on('SIGTERM', () => { 285 | console.error('Received SIGTERM signal. Shutting down...'); 286 | process.exit(0); 287 | }); 288 | }); 289 | } catch (error) { 290 | console.error('Error starting MCP server:', error); 291 | throw error; // Re-throw to allow the outer catch to handle it 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /packages/local-server/src/test/auth/oauth-cache.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { 3 | saveOAuthMetadata, 4 | loadOAuthMetadata, 5 | } from '../../auth/oauth-cache.js'; 6 | import { GleanOAuthConfig } from '../../config/config.js'; 7 | import path from 'node:path'; 8 | import fixturify from 'fixturify'; 9 | import os from 'node:os'; 10 | import { mkdtempSync } from 'node:fs'; 11 | 12 | describe('OAuth Cache', () => { 13 | let tmpDir: string; 14 | let originalEnv: NodeJS.ProcessEnv; 15 | 16 | const testConfig: GleanOAuthConfig = { 17 | baseUrl: 'https://test.example.com', 18 | issuer: 'test-issuer', 19 | clientId: 'test-client-id', 20 | authorizationEndpoint: 'https://test.example.com/auth', 21 | tokenEndpoint: 'https://test.example.com/token', 22 | authType: 'oauth', 23 | }; 24 | 25 | beforeEach(() => { 26 | originalEnv = process.env; 27 | process.env = { ...originalEnv }; 28 | 29 | // Create temp directory and set XDG_STATE_HOME to point to it 30 | tmpDir = mkdtempSync(path.join(os.tmpdir(), 'oauth-cache-test-')); 31 | process.env.XDG_STATE_HOME = tmpDir; 32 | }); 33 | 34 | afterEach(() => { 35 | process.env = originalEnv; 36 | // Clean up temp directory 37 | fixturify.writeSync(tmpDir, {}); 38 | }); 39 | 40 | describe('saveOAuthMetadata', () => { 41 | it('saves metadata with timestamp', () => { 42 | saveOAuthMetadata(testConfig); 43 | 44 | const savedData = fixturify.readSync(tmpDir); 45 | const gleanDir = savedData['glean'] as Record; 46 | expect(gleanDir).toBeDefined(); 47 | 48 | const oauthJson = JSON.parse(gleanDir['oauth.json'] as string); 49 | expect(oauthJson).toMatchObject(testConfig); 50 | expect(oauthJson.timestamp).toBeDefined(); 51 | expect(new Date(oauthJson.timestamp)).toBeInstanceOf(Date); 52 | }); 53 | }); 54 | 55 | describe('loadOAuthMetadata', () => { 56 | it('returns null when no cache file exists', () => { 57 | const result = loadOAuthMetadata(); 58 | expect(result).toBeNull(); 59 | }); 60 | 61 | it('returns null when cache is stale', () => { 62 | const sevenHoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000); 63 | 64 | fixturify.writeSync(tmpDir, { 65 | glean: { 66 | 'oauth.json': JSON.stringify({ 67 | ...testConfig, 68 | timestamp: sevenHoursAgo, 69 | }), 70 | }, 71 | }); 72 | 73 | const result = loadOAuthMetadata(); 74 | expect(result).toBeNull(); 75 | }); 76 | 77 | it('returns config when cache is fresh', () => { 78 | const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000); 79 | 80 | fixturify.writeSync(tmpDir, { 81 | glean: { 82 | 'oauth.json': JSON.stringify({ 83 | ...testConfig, 84 | timestamp: fiveHoursAgo, 85 | }), 86 | }, 87 | }); 88 | 89 | const result = loadOAuthMetadata(); 90 | expect(result).toEqual(testConfig); 91 | }); 92 | 93 | it('returns null for malformed JSON', () => { 94 | fixturify.writeSync(tmpDir, { 95 | glean: { 96 | 'oauth.json': 'invalid json', 97 | }, 98 | }); 99 | 100 | const result = loadOAuthMetadata(); 101 | expect(result).toBeNull(); 102 | }); 103 | 104 | it('returns null when required fields are missing', () => { 105 | const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000); 106 | 107 | fixturify.writeSync(tmpDir, { 108 | glean: { 109 | 'oauth.json': JSON.stringify({ 110 | baseUrl: 'https://test.example.com', 111 | // missing other required fields 112 | timestamp: fiveHoursAgo, 113 | }), 114 | }, 115 | }); 116 | 117 | const result = loadOAuthMetadata(); 118 | expect(result).toBeNull(); 119 | }); 120 | }); 121 | 122 | describe('edge cases', () => { 123 | it('returns false for exactly 6 hours old cache', () => { 124 | const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000); 125 | 126 | fixturify.writeSync(tmpDir, { 127 | glean: { 128 | 'oauth.json': JSON.stringify({ 129 | ...testConfig, 130 | timestamp: sixHoursAgo, 131 | }), 132 | }, 133 | }); 134 | 135 | const result = loadOAuthMetadata(); 136 | expect(result).toBeNull(); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /packages/local-server/src/test/auth/token-store.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeEach, afterEach } from 'vitest'; 2 | import { Tokens, loadTokens, saveTokens } from '../../auth/token-store.js'; 3 | import { TokenResponse } from '../../auth/types.js'; 4 | import path from 'node:path'; 5 | import fs from 'node:fs'; 6 | import fixturify from 'fixturify'; 7 | import os from 'node:os'; 8 | import { Logger } from '../../log/logger.js'; 9 | 10 | describe('token-store', () => { 11 | let tmpDir: string; 12 | let originalXdgStateHome: string | undefined; 13 | let stateDir: string; 14 | 15 | beforeEach(() => { 16 | Logger.reset(); 17 | // Create temp directory and set XDG_STATE_HOME 18 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-store-test-')); 19 | originalXdgStateHome = process.env.XDG_STATE_HOME; 20 | process.env.XDG_STATE_HOME = tmpDir; 21 | stateDir = path.join(tmpDir, 'glean'); 22 | }); 23 | 24 | afterEach(() => { 25 | // Restore original XDG_STATE_HOME and clean up temp directory 26 | if (originalXdgStateHome) { 27 | process.env.XDG_STATE_HOME = originalXdgStateHome; 28 | } else { 29 | delete process.env.XDG_STATE_HOME; 30 | } 31 | fs.rmSync(tmpDir, { recursive: true, force: true }); 32 | }); 33 | 34 | describe('Tokens.buildFromTokenResponse', () => { 35 | test('creates tokens with access token only', () => { 36 | const response: TokenResponse = { 37 | token_type: 'Bearer', 38 | access_token: 'access123', 39 | }; 40 | 41 | const tokens = Tokens.buildFromTokenResponse(response); 42 | expect(tokens.accessToken).toBe('access123'); 43 | expect(tokens.refreshToken).toBeUndefined(); 44 | expect(tokens.expiresAt).toBeUndefined(); 45 | }); 46 | 47 | test('creates tokens with all fields', () => { 48 | const response: TokenResponse = { 49 | token_type: 'Bearer', 50 | access_token: 'access123', 51 | refresh_token: 'refresh456', 52 | expires_in: 3600, 53 | }; 54 | 55 | const tokens = Tokens.buildFromTokenResponse(response); 56 | expect(tokens.accessToken).toBe('access123'); 57 | expect(tokens.refreshToken).toBe('refresh456'); 58 | expect(tokens.expiresAt).toBeInstanceOf(Date); 59 | 60 | // Verify expiry calculation (allowing 1s tolerance for test execution time) 61 | const expectedExpiry = new Date(Date.now() + 3600 * 1000); 62 | const actualExpiry = tokens.expiresAt!; 63 | expect( 64 | Math.abs(actualExpiry.getTime() - expectedExpiry.getTime()), 65 | ).toBeLessThan(1000); 66 | }); 67 | }); 68 | 69 | describe('loadTokens', () => { 70 | test('returns null when no tokens file exists', () => { 71 | expect(loadTokens()).toBeNull(); 72 | }); 73 | 74 | test('loads valid tokens from file', () => { 75 | const now = new Date(); 76 | const tokensContent = { 77 | accessToken: 'access123', 78 | refreshToken: 'refresh456', 79 | expiresAt: now.toISOString(), 80 | }; 81 | 82 | fixturify.writeSync(tmpDir, { 83 | glean: { 84 | 'tokens.json': JSON.stringify(tokensContent), 85 | }, 86 | }); 87 | 88 | const tokens = loadTokens(); 89 | expect(tokens).not.toBeNull(); 90 | expect(tokens?.accessToken).toBe('access123'); 91 | expect(tokens?.refreshToken).toBe('refresh456'); 92 | expect(tokens?.expiresAt?.toISOString()).toBe(now.toISOString()); 93 | }); 94 | 95 | test('returns null for malformed tokens file', () => { 96 | fixturify.writeSync(tmpDir, { 97 | glean: { 98 | 'tokens.json': 'invalid json', 99 | }, 100 | }); 101 | 102 | expect(loadTokens()).toBeNull(); 103 | }); 104 | 105 | test('handles missing optional fields', () => { 106 | const tokensContent = { 107 | accessToken: 'access123', 108 | }; 109 | 110 | fixturify.writeSync(tmpDir, { 111 | glean: { 112 | 'tokens.json': JSON.stringify(tokensContent), 113 | }, 114 | }); 115 | 116 | const tokens = loadTokens(); 117 | expect(tokens).not.toBeNull(); 118 | expect(tokens?.accessToken).toBe('access123'); 119 | expect(tokens?.refreshToken).toBeUndefined(); 120 | expect(tokens?.expiresAt).toBeUndefined(); 121 | }); 122 | }); 123 | 124 | describe('saveTokens', () => { 125 | test('saves tokens to file with correct permissions', () => { 126 | const now = new Date(); 127 | const tokens = new Tokens({ 128 | accessToken: 'access123', 129 | refreshToken: 'refresh456', 130 | expiresAt: now, 131 | }); 132 | 133 | saveTokens(tokens); 134 | 135 | // Verify file exists 136 | const tokensFile = path.join(stateDir, 'tokens.json'); 137 | expect(fs.existsSync(tokensFile)).toBe(true); 138 | 139 | // Verify file permissions (0o600) 140 | const stats = fs.statSync(tokensFile); 141 | expect(stats.mode & 0o777).toBe(0o600); 142 | 143 | // Verify content 144 | const savedContent = JSON.parse(fs.readFileSync(tokensFile, 'utf-8')); 145 | expect(savedContent).toEqual({ 146 | accessToken: 'access123', 147 | refreshToken: 'refresh456', 148 | expiresAt: now.toISOString(), 149 | }); 150 | }); 151 | 152 | test('overwrites existing tokens file', () => { 153 | // First save 154 | const tokens1 = new Tokens({ 155 | accessToken: 'access123', 156 | }); 157 | saveTokens(tokens1); 158 | 159 | // Second save 160 | const tokens2 = new Tokens({ 161 | accessToken: 'newaccess456', 162 | }); 163 | saveTokens(tokens2); 164 | 165 | // Verify content was overwritten 166 | const tokensFile = path.join(stateDir, 'tokens.json'); 167 | const savedContent = JSON.parse(fs.readFileSync(tokensFile, 'utf-8')); 168 | expect(savedContent).toEqual({ 169 | accessToken: 'newaccess456', 170 | }); 171 | }); 172 | 173 | test('creates intermediate directories if needed', () => { 174 | const tokens = new Tokens({ 175 | accessToken: 'access123', 176 | }); 177 | 178 | // Remove state directory if it exists 179 | if (fs.existsSync(stateDir)) { 180 | fs.rmSync(stateDir, { recursive: true }); 181 | } 182 | 183 | saveTokens(tokens); 184 | 185 | // Verify directory was created 186 | expect(fs.existsSync(stateDir)).toBe(true); 187 | expect(fs.existsSync(path.join(stateDir, 'tokens.json'))).toBe(true); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /packages/local-server/src/test/common/client.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | it, 4 | expect, 5 | beforeEach, 6 | afterEach, 7 | beforeAll, 8 | afterAll, 9 | } from 'vitest'; 10 | import { http } from 'msw'; 11 | import { setupServer } from 'msw/node'; 12 | import { getAPIClientOptions, resetClient } from '../../common/client.js'; 13 | import { Logger } from '../../log/logger.js'; 14 | import fs from 'node:fs'; 15 | import os from 'node:os'; 16 | import path from 'node:path'; 17 | 18 | function setupTempXDG() { 19 | const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'glean-xdg-test-')); 20 | process.env.XDG_CONFIG_HOME = path.join(tmp, 'config'); 21 | process.env.XDG_STATE_HOME = path.join(tmp, 'state'); 22 | process.env.XDG_DATA_HOME = path.join(tmp, 'data'); 23 | fs.mkdirSync(process.env.XDG_CONFIG_HOME, { recursive: true }); 24 | fs.mkdirSync(process.env.XDG_STATE_HOME, { recursive: true }); 25 | fs.mkdirSync(process.env.XDG_DATA_HOME, { recursive: true }); 26 | return tmp; 27 | } 28 | 29 | const server = setupServer(); 30 | 31 | describe('getSDKOptions (integration, msw)', () => { 32 | let tmpDir: string; 33 | let origEnv: NodeJS.ProcessEnv; 34 | const ENV_VARS = [ 35 | 'GLEAN_API_TOKEN', 36 | 'GLEAN_INSTANCE', 37 | 'GLEAN_SUBDOMAIN', 38 | 'GLEAN_BASE_URL', 39 | 'GLEAN_ACT_AS', 40 | 'GLEAN_OAUTH_CLIENT_ID', 41 | 'GLEAN_OAUTH_ISSUER', 42 | 'GLEAN_OAUTH_AUTHORIZATION_ENDPOINT', 43 | 'GLEAN_OAUTH_TOKEN_ENDPOINT', 44 | ]; 45 | let savedEnv: Record = {}; 46 | 47 | beforeAll(() => server.listen()); 48 | afterAll(() => server.close()); 49 | 50 | beforeEach(() => { 51 | origEnv = { ...process.env }; 52 | tmpDir = setupTempXDG(); 53 | resetClient(); 54 | server.resetHandlers(); 55 | savedEnv = {}; 56 | for (const key of ENV_VARS) { 57 | savedEnv[key] = process.env[key]; 58 | delete process.env[key]; 59 | } 60 | }); 61 | 62 | afterEach(() => { 63 | process.env = origEnv; 64 | Logger.reset(); 65 | fs.rmSync(tmpDir, { recursive: true, force: true }); 66 | server.resetHandlers(); 67 | for (const key of ENV_VARS) { 68 | if (savedEnv[key] !== undefined) { 69 | process.env[key] = savedEnv[key]; 70 | } else { 71 | delete process.env[key]; 72 | } 73 | } 74 | }); 75 | 76 | it('should return correct SDKOptions for Glean token config (no actAs)', async () => { 77 | // Arrange: set env vars and config 78 | process.env.GLEAN_API_TOKEN = 'test-token'; 79 | process.env.GLEAN_BASE_URL = 'https://glean.example.com'; 80 | // No actAs 81 | 82 | // Act 83 | const opts = await getAPIClientOptions(); 84 | expect(opts.apiToken).toBe('test-token'); 85 | expect(opts.serverURL).toBe('https://glean.example.com'); 86 | expect(opts.httpClient).toBeUndefined(); // No actAs, so no custom httpClient 87 | }); 88 | 89 | it('should set X-Glean-Auth-Type header for OAuth config', async () => { 90 | // Arrange: set env vars and config for OAuth 91 | process.env.GLEAN_BASE_URL = 'https://glean.example.com'; 92 | 93 | // Write oauth.json to XDG_STATE_HOME/glean/oauth.json 94 | const stateDir = path.join(process.env.XDG_STATE_HOME!, 'glean'); 95 | fs.mkdirSync(stateDir, { recursive: true }); 96 | const oauthMetadata = { 97 | baseUrl: 'https://glean.example.com', 98 | issuer: 'https://issuer.example.com', 99 | clientId: 'client-id', 100 | authorizationEndpoint: 'https://issuer.example.com/auth', 101 | tokenEndpoint: 'https://issuer.example.com/token', 102 | timestamp: new Date().toISOString(), 103 | }; 104 | fs.writeFileSync( 105 | path.join(stateDir, 'oauth.json'), 106 | JSON.stringify(oauthMetadata), 107 | ); 108 | 109 | // Write tokens.json to XDG_STATE_HOME/glean/tokens.json 110 | const tokens = { 111 | accessToken: 'oauth-access-token', 112 | refreshToken: 'refresh-token', 113 | expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), 114 | }; 115 | fs.writeFileSync( 116 | path.join(stateDir, 'tokens.json'), 117 | JSON.stringify(tokens), 118 | ); 119 | 120 | const receivedHeaders: Record = {}; 121 | server.use( 122 | http.get('https://glean.example.com/test', ({ request }) => { 123 | request.headers.forEach((value, key) => { 124 | receivedHeaders[key] = value; 125 | }); 126 | return new Response('ok', { status: 200 }); 127 | }), 128 | ); 129 | 130 | // Act 131 | const opts = await getAPIClientOptions(); 132 | expect(opts.apiToken).toBe('oauth-access-token'); 133 | expect(opts.serverURL).toBe('https://glean.example.com'); 134 | expect(opts.httpClient).toBeDefined(); 135 | 136 | // Make a request using the custom httpClient 137 | const req = new Request('https://glean.example.com/test'); 138 | await opts.httpClient!.request(req); 139 | 140 | // Assert: headers 141 | expect(receivedHeaders['x-glean-auth-type']).toBe('OAUTH'); 142 | expect(receivedHeaders['x-glean-act-as']).toBeUndefined(); 143 | // we don't set authorization in the custom http client so nothing to test 144 | // here. That's done automatically as long as we set bearerAuth, which 145 | // we've tested above. 146 | }); 147 | 148 | it('should set X-Glean-Act-As header for Glean token config with actAs', async () => { 149 | process.env.GLEAN_API_TOKEN = 'test-token'; 150 | process.env.GLEAN_BASE_URL = 'https://glean.example.com'; 151 | process.env.GLEAN_ACT_AS = 'impersonated-user'; 152 | 153 | const receivedHeaders: Record = {}; 154 | server.use( 155 | http.get('https://glean.example.com/test', ({ request }) => { 156 | request.headers.forEach((value, key) => { 157 | receivedHeaders[key] = value; 158 | }); 159 | return new Response('ok', { status: 200 }); 160 | }), 161 | ); 162 | 163 | const opts = await getAPIClientOptions(); 164 | expect(opts.apiToken).toBe('test-token'); 165 | expect(opts.serverURL).toBe('https://glean.example.com'); 166 | expect(opts.httpClient).toBeDefined(); 167 | 168 | const req = new Request('https://glean.example.com/test'); 169 | await opts.httpClient!.request(req); 170 | 171 | expect(receivedHeaders['x-glean-act-as']).toBe('impersonated-user'); 172 | expect(receivedHeaders['authorization']).toBeUndefined(); 173 | expect(receivedHeaders['x-glean-auth-type']).toBeUndefined(); 174 | }); 175 | 176 | it('should throw AuthError for basic config where we cannot fetch OAuth metadata', async () => { 177 | process.env.GLEAN_INSTANCE = 'awesome-co'; 178 | // No env vars, no config files 179 | await expect( 180 | async () => await getAPIClientOptions(), 181 | ).rejects.toThrowErrorMatchingInlineSnapshot( 182 | `[AuthError: ERR_A_06: Unable to fetch OAuth protected resource metadata: please contact your Glean administrator and ensure device flow authorization is configured correctly.]`, 183 | ); 184 | }); 185 | 186 | it('should throw error if both token and OAuth env vars are set (conflict)', async () => { 187 | process.env.GLEAN_API_TOKEN = 'test-token'; 188 | process.env.GLEAN_BASE_URL = 'https://glean.example.com'; 189 | process.env.GLEAN_OAUTH_CLIENT_ID = 'client-id'; 190 | process.env.GLEAN_OAUTH_ISSUER = 'https://issuer.example.com'; 191 | process.env.GLEAN_OAUTH_AUTHORIZATION_ENDPOINT = 192 | 'https://issuer.example.com/auth'; 193 | process.env.GLEAN_OAUTH_TOKEN_ENDPOINT = 'https://issuer.example.com/token'; 194 | await expect( 195 | async () => await getAPIClientOptions(), 196 | ).rejects.toThrowErrorMatchingInlineSnapshot( 197 | `[Error: Specify either GLEAN_OAUTH_ISSUER and GLEAN_OAUTH_CLIENT_ID or GLEAN_API_TOKEN, but not both.]`, 198 | ); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /packages/local-server/src/test/config/config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { http, HttpResponse } from 'msw'; 3 | import { server } from '../mocks/setup'; 4 | import os from 'node:os'; 5 | import path from 'node:path'; 6 | import fs from 'node:fs'; 7 | import { Logger } from '../../log/logger.js'; 8 | import { 9 | getConfig, 10 | isBasicConfig, 11 | isGleanTokenConfig, 12 | isOAuthConfig, 13 | } from '../../config/config.js'; 14 | 15 | // Helper to set up XDG temp dir 16 | function setupXdgTemp() { 17 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-')); 18 | process.env.XDG_STATE_HOME = tmpDir; 19 | return tmpDir; 20 | } 21 | 22 | describe('getConfig', () => { 23 | let tmpDir: string; 24 | let originalXdgStateHome: string | undefined; 25 | let originalEnv: NodeJS.ProcessEnv; 26 | 27 | beforeEach(() => { 28 | // Save original environment 29 | originalEnv = { ...process.env }; 30 | originalXdgStateHome = process.env.XDG_STATE_HOME; 31 | tmpDir = setupXdgTemp(); 32 | }); 33 | 34 | afterEach(() => { 35 | // Restore original environment 36 | process.env = originalEnv; 37 | if (originalXdgStateHome) { 38 | process.env.XDG_STATE_HOME = originalXdgStateHome; 39 | } else { 40 | delete process.env.XDG_STATE_HOME; 41 | } 42 | fs.rmSync(tmpDir, { recursive: true, force: true }); 43 | server.resetHandlers(); 44 | Logger.reset(); 45 | }); 46 | 47 | describe('without discoverOAuth option', () => { 48 | it('returns basic config when only instance is provided', async () => { 49 | process.env.GLEAN_INSTANCE = 'test-company'; 50 | const config = await getConfig(); 51 | expect(isBasicConfig(config)).toBe(true); 52 | expect(config).toMatchInlineSnapshot(` 53 | { 54 | "authType": "unknown", 55 | "baseUrl": "https://test-company-be.glean.com/", 56 | } 57 | `); 58 | }); 59 | 60 | it('returns token config when token is provided', async () => { 61 | process.env.GLEAN_INSTANCE = 'test-company'; 62 | process.env.GLEAN_API_TOKEN = 'test-token'; 63 | const config = await getConfig(); 64 | expect(isGleanTokenConfig(config)).toBe(true); 65 | expect(config).toMatchInlineSnapshot(` 66 | { 67 | "authType": "token", 68 | "baseUrl": "https://test-company-be.glean.com/", 69 | "token": "test-token", 70 | } 71 | `); 72 | }); 73 | 74 | it('throws error when both token and OAuth env vars are set', async () => { 75 | process.env.GLEAN_INSTANCE = 'test-company'; 76 | process.env.GLEAN_API_TOKEN = 'test-token'; 77 | process.env.GLEAN_OAUTH_ISSUER = 'https://auth.example.com'; 78 | process.env.GLEAN_OAUTH_CLIENT_ID = 'test-client'; 79 | await expect(getConfig()).rejects.toThrowErrorMatchingInlineSnapshot( 80 | `[Error: Specify either GLEAN_OAUTH_ISSUER and GLEAN_OAUTH_CLIENT_ID or GLEAN_API_TOKEN, but not both.]`, 81 | ); 82 | }); 83 | }); 84 | 85 | describe('with discoverOAuth option', () => { 86 | it('does not make network request for token config', async () => { 87 | process.env.GLEAN_INSTANCE = 'test-company'; 88 | process.env.GLEAN_API_TOKEN = 'test-token'; 89 | const baseUrl = 'https://test-company-be.glean.com'; 90 | const oauthUrl = `${baseUrl}/.well-known/oauth-protected-resource`; 91 | 92 | // Set up a handler that will fail if called 93 | server.use( 94 | http.get(oauthUrl, () => { 95 | throw new Error('Network request should not be made'); 96 | }), 97 | ); 98 | 99 | const config = await getConfig({ discoverOAuth: true }); 100 | expect(isGleanTokenConfig(config)).toBe(true); 101 | expect(config).toMatchInlineSnapshot(` 102 | { 103 | "authType": "token", 104 | "baseUrl": "https://test-company-be.glean.com/", 105 | "token": "test-token", 106 | } 107 | `); 108 | }); 109 | 110 | it('makes network request and returns OAuth config for basic config', async () => { 111 | process.env.GLEAN_INSTANCE = 'test-company'; 112 | const baseUrl = 'https://test-company-be.glean.com'; 113 | const oauthUrl = `${baseUrl}/.well-known/oauth-protected-resource`; 114 | const authUrl = 115 | 'https://auth.example.com/.well-known/openid-configuration'; 116 | 117 | // Mock the OAuth protected resource metadata endpoint 118 | server.use( 119 | http.get(oauthUrl, () => 120 | HttpResponse.json({ 121 | authorization_servers: ['https://auth.example.com'], 122 | glean_device_flow_client_id: 'test-client', 123 | }), 124 | ), 125 | // Mock the OpenID configuration endpoint 126 | http.get(authUrl, () => 127 | HttpResponse.json({ 128 | device_authorization_endpoint: 'https://auth.example.com/device', 129 | token_endpoint: 'https://auth.example.com/token', 130 | }), 131 | ), 132 | ); 133 | 134 | const config = await getConfig({ discoverOAuth: true }); 135 | expect(isOAuthConfig(config)).toBe(true); 136 | expect(config).toMatchInlineSnapshot(` 137 | { 138 | "authType": "oauth", 139 | "authorizationEndpoint": "https://auth.example.com/device", 140 | "baseUrl": "https://test-company-be.glean.com/", 141 | "clientId": "test-client", 142 | "issuer": "https://auth.example.com", 143 | "tokenEndpoint": "https://auth.example.com/token", 144 | } 145 | `); 146 | }); 147 | 148 | it('throws error when OAuth metadata fetch fails', async () => { 149 | process.env.GLEAN_INSTANCE = 'test-company'; 150 | const baseUrl = 'https://test-company-be.glean.com'; 151 | const oauthUrl = `${baseUrl}/.well-known/oauth-protected-resource`; 152 | 153 | // Mock the OAuth protected resource metadata endpoint to fail 154 | server.use(http.get(oauthUrl, () => HttpResponse.error())); 155 | 156 | await expect( 157 | getConfig({ discoverOAuth: true }), 158 | ).rejects.toThrowErrorMatchingInlineSnapshot( 159 | `[AuthError: ERR_A_06: Unable to fetch OAuth protected resource metadata: please contact your Glean administrator and ensure device flow authorization is configured correctly.]`, 160 | ); 161 | }); 162 | 163 | it('throws error when OAuth metadata is missing required fields', async () => { 164 | process.env.GLEAN_INSTANCE = 'test-company'; 165 | const baseUrl = 'https://test-company-be.glean.com'; 166 | const oauthUrl = `${baseUrl}/.well-known/oauth-protected-resource`; 167 | 168 | // Mock the OAuth protected resource metadata endpoint with missing fields 169 | server.use( 170 | http.get(oauthUrl, () => 171 | HttpResponse.json({ 172 | // Missing authorization_servers 173 | glean_device_flow_client_id: 'test-client', 174 | }), 175 | ), 176 | ); 177 | 178 | await expect( 179 | getConfig({ discoverOAuth: true }), 180 | ).rejects.toThrowErrorMatchingInlineSnapshot( 181 | `[AuthError: ERR_A_09: OAuth protected resource metadata did not include any authorization servers: please contact your Glean administrator and ensure device flow authorization is configured correctly.]`, 182 | ); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /packages/local-server/src/test/configure/vscode.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import vscodeClient from '../../configure/client/vscode.js'; 3 | import type { ConfigureOptions, MCPConfig } from '../../configure.js'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | 7 | describe('VS Code MCP Client', () => { 8 | const homedir = os.homedir(); 9 | 10 | it('should have the correct display name', () => { 11 | expect(vscodeClient.displayName).toBe('VS Code'); 12 | }); 13 | 14 | it('should generate the correct config path based on platform', () => { 15 | const platform = process.platform; 16 | let expectedPath: string; 17 | 18 | if (platform === 'win32') { 19 | expectedPath = path.join( 20 | process.env.APPDATA || '', 21 | 'Code', 22 | 'User', 23 | 'settings.json', 24 | ); 25 | } else if (platform === 'darwin') { 26 | expectedPath = path.join( 27 | homedir, 28 | 'Library', 29 | 'Application Support', 30 | 'Code', 31 | 'User', 32 | 'settings.json', 33 | ); 34 | } else { 35 | expectedPath = path.join( 36 | homedir, 37 | '.config', 38 | 'Code', 39 | 'User', 40 | 'settings.json', 41 | ); 42 | } 43 | 44 | expect(vscodeClient.configFilePath(homedir)).toBe(expectedPath); 45 | }); 46 | 47 | it('should generate workspace config path when workspace option is provided', () => { 48 | const originalCwd = process.cwd(); 49 | 50 | try { 51 | process.cwd = () => '/test/workspace'; 52 | 53 | const options: ConfigureOptions = { workspace: true }; 54 | const configPath = vscodeClient.configFilePath(homedir, options); 55 | expect(configPath).toBe('/test/workspace/.vscode/mcp.json'); 56 | } finally { 57 | process.cwd = () => originalCwd; 58 | } 59 | }); 60 | 61 | it('should generate workspace config path in any directory', () => { 62 | const originalCwd = process.cwd(); 63 | 64 | try { 65 | process.cwd = () => '/any/directory'; 66 | 67 | const options: ConfigureOptions = { workspace: true }; 68 | const configPath = vscodeClient.configFilePath(homedir, options); 69 | expect(configPath).toBe('/any/directory/.vscode/mcp.json'); 70 | } finally { 71 | process.cwd = () => originalCwd; 72 | } 73 | }); 74 | 75 | it('should generate a valid VS Code MCP config template with instance', () => { 76 | const config = vscodeClient.configTemplate( 77 | 'example-instance', 78 | 'test-token', 79 | ); 80 | 81 | expect(config).toMatchObject({ 82 | mcp: { 83 | servers: { 84 | glean: { 85 | type: 'stdio', 86 | command: 'npx', 87 | args: ['-y', '@gleanwork/mcp-server'], 88 | env: { 89 | GLEAN_INSTANCE: 'example-instance', 90 | GLEAN_API_TOKEN: 'test-token', 91 | }, 92 | }, 93 | }, 94 | }, 95 | }); 96 | }); 97 | 98 | it('should generate a valid VS Code workspace config template with instance', () => { 99 | const options: ConfigureOptions = { workspace: true }; 100 | const config = vscodeClient.configTemplate( 101 | 'example-instance', 102 | 'test-token', 103 | options, 104 | ); 105 | 106 | expect(config).toMatchObject({ 107 | servers: { 108 | glean: { 109 | type: 'stdio', 110 | command: 'npx', 111 | args: ['-y', '@gleanwork/mcp-server'], 112 | env: { 113 | GLEAN_INSTANCE: 'example-instance', 114 | GLEAN_API_TOKEN: 'test-token', 115 | }, 116 | }, 117 | }, 118 | }); 119 | }); 120 | 121 | it('should generate a valid VS Code MCP config template with URL', () => { 122 | const config = vscodeClient.configTemplate( 123 | 'https://example.com/rest/api/v1', 124 | 'test-token', 125 | ); 126 | 127 | expect(config).toMatchObject({ 128 | mcp: { 129 | servers: { 130 | glean: { 131 | env: { 132 | GLEAN_BASE_URL: 'https://example.com/rest/api/v1', 133 | GLEAN_API_TOKEN: 'test-token', 134 | }, 135 | }, 136 | }, 137 | }, 138 | }); 139 | }); 140 | 141 | it('should include success message with instructions', () => { 142 | const configPath = '/path/to/config'; 143 | const message = vscodeClient.successMessage(configPath); 144 | 145 | expect(message).toContain('VS Code MCP configuration has been configured'); 146 | expect(message).toContain(configPath); 147 | expect(message).toContain('Restart VS Code'); 148 | }); 149 | 150 | it('should include workspace-specific success message for workspace config', () => { 151 | const configPath = '/workspace/.vscode/mcp.json'; 152 | const options: ConfigureOptions = { workspace: true }; 153 | const message = vscodeClient.successMessage(configPath, options); 154 | 155 | expect(message).toContain( 156 | 'VS Code workspace MCP configuration has been configured', 157 | ); 158 | expect(message).toContain( 159 | 'This configuration is specific to this workspace', 160 | ); 161 | expect(message).toContain(configPath); 162 | }); 163 | 164 | it('should detect existing global config correctly', () => { 165 | const existingConfig = { 166 | mcp: { 167 | servers: { 168 | glean: { 169 | command: 'npx', 170 | args: ['-y', '@gleanwork/mcp-server'], 171 | }, 172 | }, 173 | }, 174 | }; 175 | 176 | expect(vscodeClient.hasExistingConfig(existingConfig)).toBe(true); 177 | }); 178 | 179 | it('should detect existing workspace config correctly', () => { 180 | const existingConfig = { 181 | servers: { 182 | glean: { 183 | command: 'npx', 184 | args: ['-y', '@gleanwork/mcp-server'], 185 | }, 186 | }, 187 | }; 188 | 189 | const options: ConfigureOptions = { workspace: true }; 190 | expect(vscodeClient.hasExistingConfig(existingConfig, options)).toBe(true); 191 | }); 192 | 193 | it('should update global config correctly', () => { 194 | const existingConfig = { someOtherConfig: true }; 195 | const newConfig: MCPConfig = { 196 | mcp: { 197 | servers: { 198 | glean: { 199 | command: 'npx', 200 | args: ['-y', '@gleanwork/mcp-server'], 201 | env: {}, 202 | }, 203 | }, 204 | }, 205 | }; 206 | 207 | const updated = vscodeClient.updateConfig(existingConfig, newConfig); 208 | 209 | expect(updated).toMatchObject({ 210 | someOtherConfig: true, 211 | mcp: newConfig.mcp, 212 | }); 213 | }); 214 | 215 | it('should update workspace config correctly', () => { 216 | const existingConfig = { someOtherConfig: true }; 217 | const newConfig: MCPConfig = { 218 | servers: { 219 | glean: { 220 | command: 'npx', 221 | args: ['-y', '@gleanwork/mcp-server'], 222 | env: {}, 223 | }, 224 | }, 225 | }; 226 | 227 | const options: ConfigureOptions = { workspace: true }; 228 | const updated = vscodeClient.updateConfig( 229 | existingConfig, 230 | newConfig, 231 | options, 232 | ); 233 | 234 | expect(updated).toMatchObject({ 235 | someOtherConfig: true, 236 | servers: newConfig.servers, 237 | }); 238 | }); 239 | 240 | it('should generate a valid VS Code workspace config template with URL', () => { 241 | const options: ConfigureOptions = { workspace: true }; 242 | const config = vscodeClient.configTemplate( 243 | 'https://example.com/rest/api/v1', 244 | 'test-token', 245 | options, 246 | ); 247 | 248 | expect(config).toMatchObject({ 249 | servers: { 250 | glean: { 251 | type: 'stdio', 252 | command: 'npx', 253 | args: ['-y', '@gleanwork/mcp-server'], 254 | env: { 255 | GLEAN_BASE_URL: 'https://example.com/rest/api/v1', 256 | GLEAN_API_TOKEN: 'test-token', 257 | }, 258 | }, 259 | }, 260 | }); 261 | }); 262 | 263 | it('should generate global config when workspace option is false', () => { 264 | const options: ConfigureOptions = { workspace: false }; 265 | const config = vscodeClient.configTemplate( 266 | 'example-instance', 267 | 'test-token', 268 | options, 269 | ); 270 | 271 | expect(config).toMatchObject({ 272 | mcp: { 273 | servers: { 274 | glean: { 275 | type: 'stdio', 276 | command: 'npx', 277 | args: ['-y', '@gleanwork/mcp-server'], 278 | env: { 279 | GLEAN_INSTANCE: 'example-instance', 280 | GLEAN_API_TOKEN: 'test-token', 281 | }, 282 | }, 283 | }, 284 | }, 285 | }); 286 | }); 287 | }); 288 | -------------------------------------------------------------------------------- /packages/local-server/src/test/formatters/chat-formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { formatResponse } from '../../tools/chat'; 3 | 4 | describe('Chat Formatter', () => { 5 | it('should format chat responses correctly', () => { 6 | const mockChatResponse = { 7 | messages: [ 8 | { 9 | author: 'USER', 10 | fragments: [ 11 | { 12 | text: 'What is Glean?', 13 | }, 14 | ], 15 | messageId: 'user-msg-1', 16 | }, 17 | { 18 | author: 'GLEAN_AI', 19 | fragments: [ 20 | { 21 | text: 'Glean is an AI platform for work that helps organizations find and understand information. It provides enterprise search, AI assistants, and agent capabilities.', 22 | }, 23 | ], 24 | citations: [ 25 | { 26 | sourceDocument: { 27 | title: 'Glean Website', 28 | url: 'https://www.glean.com/', 29 | }, 30 | }, 31 | { 32 | sourceDocument: { 33 | title: 'Glean Documentation', 34 | url: 'https://docs.glean.com/', 35 | }, 36 | }, 37 | ], 38 | messageId: 'assistant-msg-1', 39 | messageType: 'UPDATE', 40 | stepId: 'RESPOND', 41 | }, 42 | ], 43 | conversationId: 'mock-conversation-id', 44 | }; 45 | 46 | const formattedChat = formatResponse(mockChatResponse); 47 | 48 | expect(formattedChat).toContain('USER: What is Glean?'); 49 | expect(formattedChat).toContain( 50 | 'GLEAN_AI (UPDATE) [Step: RESPOND]: Glean is an AI platform for work', 51 | ); 52 | expect(formattedChat).toContain('Sources:'); 53 | expect(formattedChat).toContain( 54 | '[1] Glean Website - https://www.glean.com/', 55 | ); 56 | expect(formattedChat).toContain( 57 | '[2] Glean Documentation - https://docs.glean.com/', 58 | ); 59 | }); 60 | 61 | it('should handle query suggestion fragments', () => { 62 | const mockChatResponse = { 63 | messages: [ 64 | { 65 | author: 'GLEAN_AI', 66 | fragments: [ 67 | { 68 | querySuggestion: { 69 | query: 'What can glean assistant do', 70 | datasource: 'all', 71 | }, 72 | }, 73 | ], 74 | messageId: 'query-msg-1', 75 | messageType: 'UPDATE', 76 | stepId: 'SEARCH', 77 | }, 78 | ], 79 | }; 80 | 81 | const formattedChat = formatResponse(mockChatResponse); 82 | expect(formattedChat).toContain('GLEAN_AI (UPDATE) [Step: SEARCH]'); 83 | expect(formattedChat).toContain('Query: What can glean assistant do'); 84 | }); 85 | 86 | it('should handle structured results fragments', () => { 87 | const mockChatResponse = { 88 | messages: [ 89 | { 90 | author: 'GLEAN_AI', 91 | fragments: [ 92 | { 93 | structuredResults: [ 94 | { 95 | document: { 96 | title: 'Glean Assistant Documentation', 97 | url: 'https://docs.glean.com/assistant', 98 | }, 99 | }, 100 | { 101 | document: { 102 | title: 'Glean FAQs', 103 | url: 'https://help.glean.com/faqs', 104 | }, 105 | }, 106 | ], 107 | }, 108 | ], 109 | messageId: 'results-msg-1', 110 | messageType: 'UPDATE', 111 | stepId: 'SEARCH', 112 | }, 113 | ], 114 | }; 115 | 116 | const formattedChat = formatResponse(mockChatResponse); 117 | expect(formattedChat).toContain('GLEAN_AI (UPDATE) [Step: SEARCH]'); 118 | expect(formattedChat).toContain( 119 | 'Document: Glean Assistant Documentation (https://docs.glean.com/assistant)', 120 | ); 121 | expect(formattedChat).toContain( 122 | 'Document: Glean FAQs (https://help.glean.com/faqs)', 123 | ); 124 | }); 125 | 126 | it('should handle empty messages', () => { 127 | const emptyMessages = { 128 | messages: [], 129 | conversationId: 'empty-conversation', 130 | }; 131 | 132 | const formattedChat = formatResponse(emptyMessages); 133 | expect(formattedChat).toBe('No response received.'); 134 | }); 135 | 136 | it('should handle missing messages', () => { 137 | const noMessages = {}; 138 | const formattedChat = formatResponse(noMessages); 139 | expect(formattedChat).toBe('No response received.'); 140 | }); 141 | 142 | it('should handle messages without fragments', () => { 143 | const messagesWithoutFragments = { 144 | messages: [ 145 | { 146 | author: 'USER', 147 | }, 148 | { 149 | author: 'GLEAN_AI', 150 | citations: [ 151 | { 152 | sourceDocument: { 153 | title: 'Test Source', 154 | url: 'https://example.com', 155 | }, 156 | }, 157 | ], 158 | messageType: 'CONTENT', 159 | }, 160 | ], 161 | }; 162 | 163 | const formattedChat = formatResponse(messagesWithoutFragments); 164 | expect(formattedChat).toContain('USER:'); 165 | expect(formattedChat).toContain('GLEAN_AI (CONTENT):'); 166 | expect(formattedChat).toContain('[1] Test Source - https://example.com'); 167 | }); 168 | 169 | it('should handle messages without citations', () => { 170 | const messagesWithoutCitations = { 171 | messages: [ 172 | { 173 | author: 'USER', 174 | fragments: [ 175 | { 176 | text: 'Hello', 177 | }, 178 | ], 179 | }, 180 | { 181 | author: 'GLEAN_AI', 182 | fragments: [ 183 | { 184 | text: 'Hi there! How can I help you today?', 185 | }, 186 | ], 187 | messageType: 'CONTENT', 188 | }, 189 | ], 190 | }; 191 | 192 | const formattedChat = formatResponse(messagesWithoutCitations); 193 | expect(formattedChat).toContain('USER: Hello'); 194 | expect(formattedChat).toContain( 195 | 'GLEAN_AI (CONTENT): Hi there! How can I help you today?', 196 | ); 197 | expect(formattedChat).not.toContain('Sources:'); 198 | }); 199 | 200 | it('should handle mixed fragment types in a single message', () => { 201 | const mixedFragmentsMessage = { 202 | messages: [ 203 | { 204 | author: 'GLEAN_AI', 205 | fragments: [ 206 | { 207 | text: 'Searching for:', 208 | }, 209 | { 210 | querySuggestion: { 211 | query: 'Glean assistant capabilities', 212 | datasource: 'all', 213 | }, 214 | }, 215 | { 216 | text: 'Here are the results:', 217 | }, 218 | { 219 | structuredResults: [ 220 | { 221 | document: { 222 | title: 'Glean Assistant Features', 223 | url: 'https://docs.glean.com/features', 224 | }, 225 | }, 226 | ], 227 | }, 228 | ], 229 | messageId: 'mixed-msg-1', 230 | messageType: 'UPDATE', 231 | stepId: 'SEARCH', 232 | }, 233 | ], 234 | }; 235 | 236 | const formattedChat = formatResponse(mixedFragmentsMessage); 237 | expect(formattedChat).toContain('GLEAN_AI (UPDATE) [Step: SEARCH]'); 238 | expect(formattedChat).toContain('Searching for:'); 239 | expect(formattedChat).toContain('Query: Glean assistant capabilities'); 240 | expect(formattedChat).toContain('Here are the results:'); 241 | expect(formattedChat).toContain( 242 | 'Document: Glean Assistant Features (https://docs.glean.com/features)', 243 | ); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /packages/local-server/src/test/formatters/search-formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { formatResponse } from '../../tools/search'; 3 | 4 | describe('Search Formatter', () => { 5 | it('should format search results correctly', () => { 6 | const mockSearchResults = { 7 | results: [ 8 | { 9 | trackingToken: 10 | 'g1HUqHqv3iYv5qOq,CmUKEGcxSFVxSHF2M2lZdjVxT3EaIWdsZWFud2Vic2l0ZV8xNzYxNjM0NDMxNjgwNzEzMzAwMiIMZ2xlYW53ZWJzaXRlKgNhbGwyCERvY3VtZW50OhFQVUJMSVNIRURfQ09OVEVOVA==', 11 | document: { 12 | id: 'gleanwebsite_17616344316807133002', 13 | datasource: 'gleanwebsite', 14 | docType: 'Document', 15 | title: 16 | 'Work AI for all - AI platform for agents, assistant, search', 17 | url: 'https://www.glean.com/', 18 | }, 19 | title: 'Work AI for all - AI platform for agents, assistant, search', 20 | url: 'https://www.glean.com/', 21 | snippets: [ 22 | { 23 | snippet: '', 24 | mimeType: 'text/plain', 25 | text: 'Find & understand information', 26 | snippetTextOrdering: 1, 27 | ranges: [ 28 | { 29 | startIndex: 0, 30 | endIndex: 4, 31 | type: 'BOLD', 32 | }, 33 | { 34 | startIndex: 18, 35 | endIndex: 29, 36 | type: 'BOLD', 37 | }, 38 | ], 39 | }, 40 | { 41 | snippet: '', 42 | mimeType: 'text/plain', 43 | text: "The world's leading enterprises put AI to work with Glean.", 44 | ranges: [ 45 | { 46 | startIndex: 36, 47 | endIndex: 38, 48 | type: 'BOLD', 49 | }, 50 | { 51 | startIndex: 52, 52 | endIndex: 57, 53 | type: 'BOLD', 54 | }, 55 | ], 56 | }, 57 | { 58 | snippet: '', 59 | mimeType: 'text/plain', 60 | text: 'the power of Glean and the knowledge it has across organizations comes in. You can make the prompt engineering much smarter so that you get a better', 61 | snippetTextOrdering: 2, 62 | ranges: [ 63 | { 64 | startIndex: 13, 65 | endIndex: 18, 66 | type: 'BOLD', 67 | }, 68 | ], 69 | }, 70 | ], 71 | clusteredResults: [ 72 | // Simplified for test 73 | ], 74 | }, 75 | ], 76 | metadata: { 77 | searchedQuery: 'glean', 78 | }, 79 | }; 80 | 81 | const formattedResults = formatResponse(mockSearchResults); 82 | 83 | expect(formattedResults).toContain('Search results for "glean"'); 84 | expect(formattedResults).toContain( 85 | 'Work AI for all - AI platform for agents, assistant, search', 86 | ); 87 | expect(formattedResults).toContain('Find & understand information'); 88 | expect(formattedResults).toContain( 89 | "The world's leading enterprises put AI to work with Glean.", 90 | ); 91 | expect(formattedResults).toContain('Source: gleanwebsite'); 92 | expect(formattedResults).toContain('URL: https://www.glean.com/'); 93 | }); 94 | 95 | it('should handle empty results', () => { 96 | const emptyResults = { 97 | results: [], 98 | metadata: { 99 | searchedQuery: 'nonexistent term', 100 | }, 101 | }; 102 | 103 | const formattedResults = formatResponse(emptyResults); 104 | expect(formattedResults).toContain( 105 | 'Search results for "nonexistent term" (0 results)', 106 | ); 107 | }); 108 | 109 | it('should handle missing results', () => { 110 | const noResults = {}; 111 | const formattedResults = formatResponse(noResults); 112 | expect(formattedResults).toBe('No results found.'); 113 | }); 114 | 115 | it('should handle missing snippets', () => { 116 | const resultsWithoutSnippets = { 117 | results: [ 118 | { 119 | title: 'Test Result', 120 | url: 'https://example.com', 121 | document: { 122 | datasource: 'testdatasource', 123 | }, 124 | }, 125 | ], 126 | metadata: { 127 | searchedQuery: 'test', 128 | }, 129 | }; 130 | 131 | const formattedResults = formatResponse(resultsWithoutSnippets); 132 | expect(formattedResults).toContain('Test Result'); 133 | expect(formattedResults).toContain('No description available'); 134 | expect(formattedResults).toContain('Source: testdatasource'); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /packages/local-server/src/test/log/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { Logger, debug, trace, error, LogLevel } from '../../log/logger.js'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import os from 'node:os'; 6 | 7 | // Helper to sanitize timestamps and stack traces in log output for snapshotting 8 | function sanitizeLogOutput(log: string): string { 9 | // Replace ISO timestamps in brackets with [] 10 | let sanitized = log.replace( 11 | /\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/g, 12 | '[]', 13 | ); 14 | // Replace stack traces (Error: ... and following indented lines) with 15 | sanitized = sanitized.replace( 16 | /(Error: [^\n]+\n)([ ]+at [^\n]+\n?)+/g, 17 | 'Error: \n', 18 | ); 19 | return sanitized; 20 | } 21 | 22 | // Helper to get the log file path in the temp XDG state dir 23 | function getLogFilePath(tmpDir: string, appName = 'glean') { 24 | return path.join(tmpDir, appName, 'mcp.log'); 25 | } 26 | 27 | describe('Logger (file output, XDG, fixturify)', () => { 28 | let tmpDir: string; 29 | let originalXdgStateHome: string | undefined; 30 | 31 | beforeEach(() => { 32 | // Create a temp directory and set XDG_STATE_HOME 33 | tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-test-')); 34 | originalXdgStateHome = process.env.XDG_STATE_HOME; 35 | process.env.XDG_STATE_HOME = tmpDir; 36 | Logger.reset(); 37 | }); 38 | 39 | afterEach(() => { 40 | // Restore XDG_STATE_HOME and clean up temp dir 41 | if (originalXdgStateHome) { 42 | process.env.XDG_STATE_HOME = originalXdgStateHome; 43 | } else { 44 | delete process.env.XDG_STATE_HOME; 45 | } 46 | fs.rmSync(tmpDir, { recursive: true, force: true }); 47 | }); 48 | 49 | it('writes debug, trace, and error messages to the log file at TRACE level', () => { 50 | const logger = Logger.getInstance(); 51 | logger.setLogLevel(LogLevel.TRACE); 52 | debug('debug message'); 53 | trace('trace message'); 54 | error('error message'); 55 | 56 | const logFilePath = getLogFilePath(tmpDir); 57 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 58 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 59 | "[] [DEBUG] debug message 60 | [] [TRACE] trace message 61 | [] [ERROR] error message 62 | " 63 | `); 64 | }); 65 | 66 | it('does not write debug or trace at ERROR level, but writes error', () => { 67 | const logger = Logger.getInstance(); 68 | logger.setLogLevel(LogLevel.ERROR); 69 | debug('debug message'); 70 | trace('trace message'); 71 | error('error message'); 72 | 73 | const logFilePath = getLogFilePath(tmpDir); 74 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 75 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 76 | "[] [ERROR] error message 77 | " 78 | `); 79 | }); 80 | 81 | it('writes debug but not trace at DEBUG level', () => { 82 | const logger = Logger.getInstance(); 83 | logger.setLogLevel(LogLevel.DEBUG); 84 | debug('debug message'); 85 | trace('trace message'); 86 | error('error message'); 87 | 88 | const logFilePath = getLogFilePath(tmpDir); 89 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 90 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 91 | "[] [DEBUG] debug message 92 | [] [ERROR] error message 93 | " 94 | `); 95 | }); 96 | 97 | it('writes only error at ERROR level', () => { 98 | const logger = Logger.getInstance(); 99 | logger.setLogLevel(LogLevel.ERROR); 100 | debug('debug message'); 101 | trace('trace message'); 102 | error('error message'); 103 | 104 | const logFilePath = getLogFilePath(tmpDir); 105 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 106 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 107 | "[] [ERROR] error message 108 | " 109 | `); 110 | }); 111 | 112 | it('logs Error objects with name, message, and stack trace', () => { 113 | const logger = Logger.getInstance(); 114 | logger.setLogLevel(LogLevel.TRACE); 115 | const err = new Error('something went wrong'); 116 | error('an error occurred', err); 117 | 118 | const logFilePath = getLogFilePath(tmpDir); 119 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 120 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 121 | "[] [ERROR] an error occurred [Error: something went wrong] 122 | Error: 123 | " 124 | `); 125 | }); 126 | 127 | it('logs non-Error objects as JSON', () => { 128 | const logger = Logger.getInstance(); 129 | logger.setLogLevel(LogLevel.TRACE); 130 | debug('object log', { foo: 'bar', baz: 42 }); 131 | 132 | const logFilePath = getLogFilePath(tmpDir); 133 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 134 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 135 | "[] [DEBUG] object log {"foo":"bar","baz":42} 136 | " 137 | `); 138 | }); 139 | 140 | it('logs Error objects with nested causes', () => { 141 | const logger = Logger.getInstance(); 142 | logger.setLogLevel(LogLevel.TRACE); 143 | const root = new Error('root cause'); 144 | const mid = new Error('mid cause'); 145 | (mid as any).cause = root; 146 | const top = new Error('top level'); 147 | (top as any).cause = mid; 148 | error('error with causes', top); 149 | 150 | const logFilePath = getLogFilePath(tmpDir); 151 | const logContent = fs.readFileSync(logFilePath, 'utf8'); 152 | expect(sanitizeLogOutput(logContent)).toMatchInlineSnapshot(` 153 | "[] [ERROR] error with causes [Error: top level] 154 | Error: 155 | Caused by: [Error: mid cause] 156 | Caused by: [Error: root cause] 157 | " 158 | `); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /packages/local-server/src/test/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw'; 2 | 3 | export const handlers = [ 4 | http.get( 5 | 'https://:instance-be.glean.com/liveness_check', 6 | async ({ params }) => { 7 | const { instance } = params; 8 | 9 | if (instance === 'invalid-instance') { 10 | return new HttpResponse(null, { 11 | status: 404, 12 | statusText: 'Not Found', 13 | }); 14 | } 15 | 16 | if (instance === 'network-error') { 17 | const error = new Error('Network error'); 18 | error.name = 'FetchError'; 19 | throw error; 20 | } 21 | 22 | return new HttpResponse(JSON.stringify({ status: 'ok' }), { 23 | status: 200, 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | }, 27 | }); 28 | }, 29 | ), 30 | http.post( 31 | 'https://:instance-be.glean.com/rest/api/v1/search', 32 | async ({ request }) => { 33 | const authHeader = request.headers.get('Authorization'); 34 | 35 | if (!authHeader || authHeader === 'Bearer invalid_token') { 36 | return new HttpResponse('Invalid Secret\nNot allowed', { 37 | status: 401, 38 | statusText: 'Unauthorized', 39 | headers: { 40 | 'Content-Type': 'text/plain; charset=utf-8', 41 | }, 42 | }); 43 | } 44 | 45 | if (authHeader === 'Bearer expired_token') { 46 | return new HttpResponse('Token has expired\nNot allowed', { 47 | status: 401, 48 | statusText: 'Unauthorized', 49 | headers: { 50 | 'Content-Type': 'text/plain; charset=utf-8', 51 | }, 52 | }); 53 | } 54 | 55 | if (authHeader === 'Bearer network_error') { 56 | const error = new Error('Network error'); 57 | error.name = 'FetchError'; 58 | throw error; 59 | } 60 | 61 | if (authHeader === 'Bearer server_error') { 62 | return new HttpResponse('Something went wrong', { 63 | status: 500, 64 | statusText: 'Internal Server Error', 65 | headers: { 66 | 'Content-Type': 'text/plain; charset=utf-8', 67 | }, 68 | }); 69 | } 70 | 71 | return HttpResponse.json({ 72 | results: [], 73 | trackingToken: 'mock-tracking-token', 74 | sessionInfo: { 75 | sessionTrackingToken: 'mock-session-token', 76 | tabId: 'mock-tab-id', 77 | lastSeen: new Date().toISOString(), 78 | lastQuery: '', 79 | }, 80 | }); 81 | }, 82 | ), 83 | 84 | http.post( 85 | 'https://:instance-be.glean.com/rest/api/v1/chat', 86 | async ({ request }) => { 87 | const authHeader = request.headers.get('Authorization'); 88 | 89 | if (!authHeader || authHeader === 'Bearer invalid_token') { 90 | return new HttpResponse('Invalid Secret\nNot allowed', { 91 | status: 401, 92 | statusText: 'Unauthorized', 93 | headers: { 94 | 'Content-Type': 'text/plain; charset=utf-8', 95 | }, 96 | }); 97 | } 98 | 99 | if (authHeader === 'Bearer expired_token') { 100 | return new HttpResponse('Token has expired\nNot allowed', { 101 | status: 401, 102 | statusText: 'Unauthorized', 103 | headers: { 104 | 'Content-Type': 'text/plain; charset=utf-8', 105 | }, 106 | }); 107 | } 108 | 109 | if (authHeader === 'Bearer network_error') { 110 | const error = new Error('Network error'); 111 | error.name = 'FetchError'; 112 | throw error; 113 | } 114 | 115 | if (authHeader === 'Bearer server_error') { 116 | return new HttpResponse('Something went wrong', { 117 | status: 500, 118 | statusText: 'Internal Server Error', 119 | headers: { 120 | 'Content-Type': 'text/plain; charset=utf-8', 121 | }, 122 | }); 123 | } 124 | 125 | const responseData = JSON.stringify({ 126 | messages: [ 127 | { 128 | author: 'GLEAN_AI', 129 | fragments: [ 130 | { 131 | text: 'Search company knowledge', 132 | }, 133 | ], 134 | messageId: '7e4c1449e53f4d5fa4eb36fca305db20', 135 | messageType: 'UPDATE', 136 | stepId: 'SEARCH', 137 | workflowId: 'ORIGINAL_MESSAGE_SEARCH', 138 | }, 139 | ], 140 | followUpPrompts: [], 141 | }); 142 | 143 | return new HttpResponse(responseData, { 144 | status: 200, 145 | headers: { 146 | 'Content-Type': 'application/json', 147 | }, 148 | }); 149 | }, 150 | ), 151 | 152 | // Handler for people profile search (listentities) 153 | http.post( 154 | 'https://:instance-be.glean.com/rest/api/v1/listentities', 155 | async ({ request }) => { 156 | const authHeader = request.headers.get('Authorization'); 157 | 158 | if (!authHeader || authHeader === 'Bearer invalid_token') { 159 | return new HttpResponse('Invalid Secret\nNot allowed', { 160 | status: 401, 161 | statusText: 'Unauthorized', 162 | headers: { 163 | 'Content-Type': 'text/plain; charset=utf-8', 164 | }, 165 | }); 166 | } 167 | 168 | if (authHeader === 'Bearer expired_token') { 169 | return new HttpResponse('Token has expired\nNot allowed', { 170 | status: 401, 171 | statusText: 'Unauthorized', 172 | headers: { 173 | 'Content-Type': 'text/plain; charset=utf-8', 174 | }, 175 | }); 176 | } 177 | 178 | if (authHeader === 'Bearer network_error') { 179 | const error = new Error('Network error'); 180 | error.name = 'FetchError'; 181 | throw error; 182 | } 183 | 184 | if (authHeader === 'Bearer server_error') { 185 | return new HttpResponse('Something went wrong', { 186 | status: 500, 187 | statusText: 'Internal Server Error', 188 | headers: { 189 | 'Content-Type': 'text/plain; charset=utf-8', 190 | }, 191 | }); 192 | } 193 | 194 | const responseData = { 195 | results: [ 196 | { 197 | name: 'Jane Doe', 198 | obfuscatedId: 'abc123', 199 | metadata: { 200 | title: 'Software Engineer', 201 | department: 'Engineering', 202 | location: 'San Francisco', 203 | email: 'jane.doe@example.com', 204 | }, 205 | }, 206 | ], 207 | totalCount: 1, 208 | hasMoreResults: false, 209 | }; 210 | 211 | return HttpResponse.json(responseData); 212 | }, 213 | ), 214 | ]; 215 | -------------------------------------------------------------------------------- /packages/local-server/src/test/mocks/setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, afterAll, afterEach } from 'vitest'; 2 | import { setupServer } from 'msw/node'; 3 | import { handlers } from './handlers'; 4 | 5 | export const server = setupServer(...handlers); 6 | 7 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); 8 | afterEach(() => server.resetHandlers()); 9 | afterAll(() => server.close()); 10 | -------------------------------------------------------------------------------- /packages/local-server/src/test/server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { listToolsHandler, callToolHandler, TOOL_NAMES } from '../server.js'; 3 | import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; 4 | import './mocks/setup'; 5 | 6 | describe('MCP Server Handlers (integration)', () => { 7 | beforeEach(() => { 8 | delete process.env.GLEAN_BASE_URL; 9 | process.env.GLEAN_INSTANCE = 'test'; 10 | process.env.GLEAN_API_TOKEN = 'test-token'; 11 | }); 12 | 13 | afterEach(() => { 14 | delete process.env.GLEAN_INSTANCE; 15 | delete process.env.GLEAN_API_TOKEN; 16 | }); 17 | 18 | it('lists all expected tools with valid JSON schema', async () => { 19 | const { tools } = await listToolsHandler(); 20 | const names = tools.map((t: any) => t.name); 21 | 22 | expect(names).toEqual( 23 | expect.arrayContaining([ 24 | TOOL_NAMES.companySearch, 25 | TOOL_NAMES.chat, 26 | TOOL_NAMES.peopleProfileSearch, 27 | ]), 28 | ); 29 | 30 | tools.forEach((tool: any) => { 31 | expect(tool.inputSchema).toHaveProperty('$schema'); 32 | }); 33 | }); 34 | 35 | it('executes the people_profile_search tool end-to-end', async () => { 36 | const request = CallToolRequestSchema.parse({ 37 | method: 'tools/call', 38 | id: '1', 39 | jsonrpc: '2.0', 40 | params: { 41 | name: TOOL_NAMES.peopleProfileSearch, 42 | arguments: { query: 'Steve' }, 43 | }, 44 | }); 45 | 46 | const response = await callToolHandler(request); 47 | 48 | expect(response.isError).toBe(false); 49 | expect(response.content[0].text).toMatch(/Software Engineer/); 50 | expect(response.content[0].text).toMatch(/Engineering/); 51 | }); 52 | 53 | it('returns validation error for missing arguments', async () => { 54 | const badRequest = { 55 | method: 'tools/call', 56 | params: { name: TOOL_NAMES.companySearch }, 57 | } as any; 58 | 59 | const result = await callToolHandler(badRequest); 60 | expect(result.isError).toBe(true); 61 | expect(result.content[0].text).toMatch(/Arguments are required/); 62 | }); 63 | 64 | it('executes company_search tool happy path', async () => { 65 | const req = CallToolRequestSchema.parse({ 66 | method: 'tools/call', 67 | id: '2', 68 | jsonrpc: '2.0', 69 | params: { 70 | name: TOOL_NAMES.companySearch, 71 | arguments: { query: 'vacation policy' }, 72 | }, 73 | }); 74 | 75 | const res = await callToolHandler(req); 76 | expect(res).toMatchInlineSnapshot(` 77 | { 78 | "content": [ 79 | { 80 | "text": "Error: Cannot read properties of undefined (reading 'searchedQuery')", 81 | "type": "text", 82 | }, 83 | ], 84 | "isError": true, 85 | } 86 | `); 87 | }); 88 | 89 | it('executes chat tool happy path', async () => { 90 | const req = CallToolRequestSchema.parse({ 91 | method: 'tools/call', 92 | id: '3', 93 | jsonrpc: '2.0', 94 | params: { 95 | name: TOOL_NAMES.chat, 96 | arguments: { message: 'hello' }, 97 | }, 98 | }); 99 | 100 | const res = await callToolHandler(req); 101 | expect(res).toMatchInlineSnapshot(` 102 | { 103 | "content": [ 104 | { 105 | "text": "GLEAN_AI (UPDATE): Search company knowledge", 106 | "type": "text", 107 | }, 108 | ], 109 | "isError": false, 110 | } 111 | `); 112 | }); 113 | 114 | it('returns Zod validation error when query is wrong type', async () => { 115 | const badReq = { 116 | method: 'tools/call', 117 | id: '4', 118 | jsonrpc: '2.0', 119 | params: { 120 | name: TOOL_NAMES.companySearch, 121 | arguments: { query: 123 }, 122 | }, 123 | } as any; 124 | 125 | const res = await callToolHandler(badReq); 126 | expect(res).toMatchInlineSnapshot(` 127 | { 128 | "content": [ 129 | { 130 | "text": "Invalid input: 131 | query: Expected string, received number", 132 | "type": "text", 133 | }, 134 | ], 135 | "isError": true, 136 | } 137 | `); 138 | }); 139 | 140 | it('returns error for unknown tool', async () => { 141 | const badReq = CallToolRequestSchema.parse({ 142 | method: 'tools/call', 143 | id: '5', 144 | jsonrpc: '2.0', 145 | params: { 146 | name: 'nonexistent_tool', 147 | arguments: {}, 148 | }, 149 | }); 150 | 151 | const res = await callToolHandler(badReq); 152 | expect(res.isError).toBe(true); 153 | expect(res.content[0].text).toMatchInlineSnapshot( 154 | `"Error: Unknown tool: nonexistent_tool"`, 155 | ); 156 | }); 157 | 158 | it('executes people_profile_search with filters only', async () => { 159 | const req = CallToolRequestSchema.parse({ 160 | method: 'tools/call', 161 | id: '6', 162 | jsonrpc: '2.0', 163 | params: { 164 | name: TOOL_NAMES.peopleProfileSearch, 165 | arguments: { 166 | filters: { department: 'Engineering' }, 167 | pageSize: 5, 168 | }, 169 | }, 170 | }); 171 | 172 | const res = await callToolHandler(req); 173 | expect(res).toMatchInlineSnapshot(` 174 | { 175 | "content": [ 176 | { 177 | "text": "Found 1 people: 178 | 179 | 1. Jane Doe – Software Engineer, Engineering (San Francisco) • jane.doe@example.com", 180 | "type": "text", 181 | }, 182 | ], 183 | "isError": false, 184 | } 185 | `); 186 | }); 187 | 188 | it('validation error when neither query nor filters provided', async () => { 189 | const badReq = CallToolRequestSchema.parse({ 190 | method: 'tools/call', 191 | id: '7', 192 | jsonrpc: '2.0', 193 | params: { 194 | name: TOOL_NAMES.peopleProfileSearch, 195 | arguments: {}, 196 | }, 197 | }); 198 | 199 | const res = await callToolHandler(badReq); 200 | expect(res).toMatchInlineSnapshot(` 201 | { 202 | "content": [ 203 | { 204 | "text": "Invalid input: 205 | : At least one of "query" or "filters" must be provided.", 206 | "type": "text", 207 | }, 208 | ], 209 | "isError": true, 210 | } 211 | `); 212 | }); 213 | 214 | it('validation error when pageSize is out of range', async () => { 215 | const badReq = CallToolRequestSchema.parse({ 216 | method: 'tools/call', 217 | id: '8', 218 | jsonrpc: '2.0', 219 | params: { 220 | name: TOOL_NAMES.peopleProfileSearch, 221 | arguments: { query: 'Steve', pageSize: 500 }, 222 | }, 223 | }); 224 | 225 | const res = await callToolHandler(badReq); 226 | expect(res).toMatchInlineSnapshot(` 227 | { 228 | "content": [ 229 | { 230 | "text": "Invalid input: 231 | pageSize: Number must be less than or equal to 100", 232 | "type": "text", 233 | }, 234 | ], 235 | "isError": true, 236 | } 237 | `); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /packages/local-server/src/test/tools/chat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { ChatResponse, Author } from '@gleanwork/api-client/models/components'; 3 | import { chat, ToolChatSchema } from '../../tools/chat'; 4 | import { zodToJsonSchema } from 'zod-to-json-schema'; 5 | import '../mocks/setup'; 6 | 7 | describe('Chat Tool', () => { 8 | beforeEach(() => { 9 | // delete BASE_URL because it takes precedence over INSTANCE 10 | delete process.env.GLEAN_BASE_URL; 11 | process.env.GLEAN_INSTANCE = 'test'; 12 | process.env.GLEAN_API_TOKEN = 'test-token'; 13 | }); 14 | 15 | afterEach(() => { 16 | delete process.env.GLEAN_INSTANCE; 17 | delete process.env.GLEAN_API_TOKEN; 18 | }); 19 | 20 | describe('JSON Schema Generation', () => { 21 | it('generates correct JSON schema', () => { 22 | expect(zodToJsonSchema(ToolChatSchema, 'GleanChat')) 23 | .toMatchInlineSnapshot(` 24 | { 25 | "$ref": "#/definitions/GleanChat", 26 | "$schema": "http://json-schema.org/draft-07/schema#", 27 | "definitions": { 28 | "GleanChat": { 29 | "additionalProperties": false, 30 | "properties": { 31 | "context": { 32 | "description": "Optional previous messages for context. Will be included in order before the current message.", 33 | "items": { 34 | "type": "string", 35 | }, 36 | "type": "array", 37 | }, 38 | "message": { 39 | "description": "The user question or message to send to Glean Assistant.", 40 | "type": "string", 41 | }, 42 | }, 43 | "required": [ 44 | "message", 45 | ], 46 | "type": "object", 47 | }, 48 | }, 49 | } 50 | `); 51 | }); 52 | }); 53 | 54 | describe('Schema Validation', () => { 55 | it('should validate a valid chat request', () => { 56 | const validRequest = { 57 | message: 'Hello', 58 | }; 59 | 60 | const result = ToolChatSchema.safeParse(validRequest); 61 | expect(result.success).toBe(true); 62 | }); 63 | 64 | it('should validate with context messages', () => { 65 | const validRequest = { 66 | message: 'How do I solve this problem?', 67 | context: [ 68 | 'I need help with an integration issue', 69 | 'I tried following the documentation', 70 | ], 71 | }; 72 | 73 | const result = ToolChatSchema.safeParse(validRequest); 74 | expect(result.success).toBe(true); 75 | }); 76 | 77 | it('should reject invalid message structure', () => { 78 | const invalidRequest = { 79 | message: 123, // Should be string 80 | context: 'not an array', // Should be an array of strings 81 | }; 82 | 83 | const result = ToolChatSchema.safeParse(invalidRequest); 84 | expect(result.success).toBe(false); 85 | }); 86 | }); 87 | 88 | describe('Tool Implementation', () => { 89 | it('should call Glean client with validated params', async () => { 90 | const params = { 91 | message: 'What are the company holidays this year?', 92 | }; 93 | 94 | const response = await chat(params); 95 | 96 | let typedResponse: ChatResponse; 97 | if (typeof response === 'string') { 98 | typedResponse = JSON.parse(response) as ChatResponse; 99 | } else { 100 | typedResponse = response as ChatResponse; 101 | } 102 | 103 | expect(typedResponse).toHaveProperty('messages'); 104 | expect(typedResponse.messages).toBeInstanceOf(Array); 105 | expect(typedResponse.messages?.[0]).toMatchObject({ 106 | author: Author.GleanAi, 107 | fragments: [ 108 | { 109 | text: 'Search company knowledge', 110 | }, 111 | ], 112 | messageId: expect.any(String), 113 | messageType: 'UPDATE', 114 | }); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /packages/local-server/src/test/tools/people_profile_search.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { 3 | ToolPeopleProfileSearchSchema, 4 | peopleProfileSearch, 5 | } from '../../tools/people_profile_search'; 6 | import { zodToJsonSchema } from 'zod-to-json-schema'; 7 | import { ListEntitiesResponse } from '@gleanwork/api-client/models/components'; 8 | import '../mocks/setup'; 9 | 10 | describe('People Profile Search Tool', () => { 11 | beforeEach(() => { 12 | delete process.env.GLEAN_BASE_URL; 13 | process.env.GLEAN_INSTANCE = 'test'; 14 | process.env.GLEAN_API_TOKEN = 'test-token'; 15 | }); 16 | 17 | afterEach(() => { 18 | delete process.env.GLEAN_INSTANCE; 19 | delete process.env.GLEAN_API_TOKEN; 20 | }); 21 | 22 | describe('JSON Schema Generation', () => { 23 | it('generates correct JSON schema', () => { 24 | expect( 25 | zodToJsonSchema(ToolPeopleProfileSearchSchema, 'PeopleProfileSearch'), 26 | ).toMatchInlineSnapshot(` 27 | { 28 | "$ref": "#/definitions/PeopleProfileSearch", 29 | "$schema": "http://json-schema.org/draft-07/schema#", 30 | "definitions": { 31 | "PeopleProfileSearch": { 32 | "additionalProperties": false, 33 | "properties": { 34 | "filters": { 35 | "additionalProperties": { 36 | "type": "string", 37 | }, 38 | "description": "Allowed facet fields: email, first_name, last_name, manager_email, department, title, location, city, country, state, region, business_unit, team, team_id, nickname, preferred_name, roletype, reportsto, startafter, startbefore, industry, has, from. Provide as { "facet": "value" }.", 39 | "propertyNames": { 40 | "enum": [ 41 | "email", 42 | "first_name", 43 | "last_name", 44 | "manager_email", 45 | "department", 46 | "title", 47 | "location", 48 | "city", 49 | "country", 50 | "state", 51 | "region", 52 | "business_unit", 53 | "team", 54 | "team_id", 55 | "nickname", 56 | "preferred_name", 57 | "roletype", 58 | "reportsto", 59 | "startafter", 60 | "startbefore", 61 | "industry", 62 | "has", 63 | "from", 64 | ], 65 | }, 66 | "type": "object", 67 | }, 68 | "pageSize": { 69 | "description": "Hint to the server for how many people to return (1-100, default 10).", 70 | "maximum": 100, 71 | "minimum": 1, 72 | "type": "integer", 73 | }, 74 | "query": { 75 | "description": "Free-text query to search people by name, title, etc.", 76 | "type": "string", 77 | }, 78 | }, 79 | "type": "object", 80 | }, 81 | }, 82 | } 83 | `); 84 | }); 85 | }); 86 | 87 | describe('Schema Validation', () => { 88 | it('validates query only', () => { 89 | const result = ToolPeopleProfileSearchSchema.safeParse({ query: 'Jane' }); 90 | expect(result.success).toBe(true); 91 | }); 92 | 93 | it('validates filters only', () => { 94 | const result = ToolPeopleProfileSearchSchema.safeParse({ 95 | filters: { department: 'Engineering' }, 96 | }); 97 | expect(result.success).toBe(true); 98 | }); 99 | 100 | it('rejects empty request', () => { 101 | const result = ToolPeopleProfileSearchSchema.safeParse({}); 102 | expect(result.success).toBe(false); 103 | }); 104 | }); 105 | 106 | describe('Tool Implementation', () => { 107 | it('calls Glean client and returns results', async () => { 108 | const response = await peopleProfileSearch({ query: 'Jane' }); 109 | const typedResponse = response as ListEntitiesResponse; 110 | 111 | expect(typedResponse.results).toBeInstanceOf(Array); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /packages/local-server/src/test/tools/search.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { SearchResponse } from '@gleanwork/api-client/models/components'; 3 | import { ToolSearchSchema, search } from '../../tools/search'; 4 | import { zodToJsonSchema } from 'zod-to-json-schema'; 5 | import '../mocks/setup'; 6 | 7 | describe('Search Tool', () => { 8 | beforeEach(() => { 9 | // delete BASE_URL because it takes precedence over INSTANCE 10 | delete process.env.GLEAN_BASE_URL; 11 | process.env.GLEAN_INSTANCE = 'test'; 12 | process.env.GLEAN_API_TOKEN = 'test-token'; 13 | }); 14 | 15 | afterEach(() => { 16 | delete process.env.GLEAN_INSTANCE; 17 | delete process.env.GLEAN_API_TOKEN; 18 | }); 19 | 20 | describe('JSON Schema Generation', () => { 21 | it('generates correct JSON schema', () => { 22 | expect(zodToJsonSchema(ToolSearchSchema, 'GleanSearch')) 23 | .toMatchInlineSnapshot(` 24 | { 25 | "$ref": "#/definitions/GleanSearch", 26 | "$schema": "http://json-schema.org/draft-07/schema#", 27 | "definitions": { 28 | "GleanSearch": { 29 | "additionalProperties": false, 30 | "properties": { 31 | "datasources": { 32 | "description": "Optional list of data sources to search in. Examples: "github", "gdrive", "confluence", "jira".", 33 | "items": { 34 | "type": "string", 35 | }, 36 | "type": "array", 37 | }, 38 | "query": { 39 | "description": "The search query. This is what you want to search for.", 40 | "type": "string", 41 | }, 42 | }, 43 | "required": [ 44 | "query", 45 | ], 46 | "type": "object", 47 | }, 48 | }, 49 | } 50 | `); 51 | }); 52 | }); 53 | 54 | describe('Schema Validation', () => { 55 | it('should validate a valid search request', () => { 56 | const validRequest = { 57 | query: 'test query', 58 | }; 59 | 60 | const result = ToolSearchSchema.safeParse(validRequest); 61 | expect(result.success).toBe(true); 62 | }); 63 | 64 | it('should validate with datasources', () => { 65 | const validRequest = { 66 | query: 'test query', 67 | datasources: ['github', 'drive'], 68 | }; 69 | 70 | const result = ToolSearchSchema.safeParse(validRequest); 71 | expect(result.success).toBe(true); 72 | }); 73 | 74 | it('should reject invalid types', () => { 75 | const invalidRequest = { 76 | query: 123, // Should be string 77 | datasources: 'github', // Should be an array 78 | }; 79 | 80 | const result = ToolSearchSchema.safeParse(invalidRequest); 81 | expect(result.success).toBe(false); 82 | }); 83 | }); 84 | 85 | describe('Tool Implementation', () => { 86 | it('should call Glean client with validated params', async () => { 87 | const params = { 88 | query: 'test query', 89 | }; 90 | 91 | const response = await search(params); 92 | const typedResponse = response as SearchResponse; 93 | 94 | expect(typedResponse).toHaveProperty('results'); 95 | expect(typedResponse.results).toBeInstanceOf(Array); 96 | expect(typedResponse).toHaveProperty('trackingToken'); 97 | expect(typedResponse).toHaveProperty('sessionInfo'); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /packages/local-server/src/test/util/preflight.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { validateInstance } from '../../util/preflight.js'; 3 | import '../mocks/setup'; 4 | 5 | describe('Preflight Validation', () => { 6 | beforeEach(() => { 7 | // Reset any environment variables that might affect the tests 8 | delete process.env.GLEAN_BASE_URL; 9 | delete process.env.GLEAN_INSTANCE; 10 | }); 11 | 12 | it('returns true for a valid instance name', async () => { 13 | const result = await validateInstance('valid-instance'); 14 | expect(result).toBe(true); 15 | }); 16 | 17 | it('returns false for invalid instance name', async () => { 18 | expect(await validateInstance('invalid-instance')).toEqual(false); 19 | }); 20 | 21 | it('returns false for network errors', async () => { 22 | expect(await validateInstance('network-error')).toEqual(false); 23 | }); 24 | 25 | it('returns false if `instance` is missing', async () => { 26 | expect(await validateInstance('')).toEqual(false); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/local-server/src/test/validate-flags.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2 | import { mockConsole } from 'console-test-helpers'; 3 | import { validateFlags } from '../index.js'; 4 | 5 | describe('validateFlags', () => { 6 | let resetConsole: () => void; 7 | let consoleState: any; 8 | let originalEnv: NodeJS.ProcessEnv; 9 | 10 | beforeEach(() => { 11 | ({ resetConsole, consoleState } = mockConsole()); 12 | originalEnv = { ...process.env }; 13 | 14 | delete process.env.GLEAN_API_TOKEN; 15 | delete process.env.GLEAN_INSTANCE; 16 | delete process.env.GLEAN_SUBDOMAIN; 17 | delete process.env.GLEAN_BASE_URL; 18 | }); 19 | 20 | afterEach(() => { 21 | resetConsole(); 22 | process.env = originalEnv; 23 | }); 24 | 25 | it('should return false when client is not provided', async () => { 26 | const result = await validateFlags( 27 | undefined, 28 | 'token', 29 | 'instance', 30 | undefined, 31 | undefined, 32 | ); 33 | 34 | expect(result).toBe(false); 35 | expect(consoleState.getState('error')).toMatchInlineSnapshot(` 36 | "Error: --client parameter is required 37 | Run with --help for usage information" 38 | `); 39 | }); 40 | 41 | it('should return true when both token/instance and env are provided (flags take priority)', async () => { 42 | const result = await validateFlags( 43 | 'client', 44 | 'token', 45 | 'instance', 46 | undefined, 47 | 'env-path', 48 | ); 49 | 50 | expect(result).toBe(true); 51 | expect(consoleState.getState('error')).toEqual(''); 52 | }); 53 | 54 | it('should return false when neither instance nor url is provided and no environment variables', async () => { 55 | const result = await validateFlags( 56 | 'client', 57 | undefined, 58 | undefined, 59 | undefined, 60 | undefined, 61 | ); 62 | 63 | expect(result).toBe(false); 64 | expect(consoleState.getState('error')).toMatchInlineSnapshot(` 65 | "Error: You must provide either: 66 | 1. Both --token and --instance for authentication, or 67 | 2. --env pointing to a .env file containing GLEAN_INSTANCE and GLEAN_API_TOKEN 68 | Run with --help for usage information" 69 | `); 70 | }); 71 | 72 | it('should return true but show warning when only token is provided', async () => { 73 | const result = await validateFlags( 74 | 'client', 75 | 'token', 76 | undefined, 77 | undefined, 78 | undefined, 79 | ); 80 | 81 | expect(result).toBe(true); 82 | expect(consoleState.getState('error')).toMatchInlineSnapshot(` 83 | " 84 | "Warning: Configuring without complete credentials. 85 | You must provide either: 86 | 1. Both --token and --instance, or 87 | 2. --env pointing to a .env file containing GLEAN_API_TOKEN and GLEAN_INSTANCE 88 | 89 | Continuing with configuration, but you will need to set credentials manually later." 90 | " 91 | `); 92 | }); 93 | 94 | it('should return true when only instance is provided (OAuth flow)', async () => { 95 | const result = await validateFlags( 96 | 'client', 97 | undefined, 98 | 'instance', 99 | undefined, 100 | undefined, 101 | ); 102 | 103 | expect(result).toBe(true); 104 | expect(consoleState.getState('error')).toEqual(''); 105 | }); 106 | 107 | it('should return true when both token and instance are provided', async () => { 108 | const result = await validateFlags( 109 | 'client', 110 | 'token', 111 | 'instance', 112 | undefined, 113 | undefined, 114 | ); 115 | 116 | expect(result).toBe(true); 117 | expect(consoleState.getState('error')).toEqual(''); 118 | }); 119 | 120 | it('should return true when env path is provided', async () => { 121 | const result = await validateFlags( 122 | 'client', 123 | undefined, 124 | undefined, 125 | undefined, 126 | 'env-path', 127 | ); 128 | 129 | expect(result).toBe(true); 130 | expect(consoleState.getState('error')).toEqual(''); 131 | }); 132 | 133 | it('should return true when both token and instance are available via environment variables', async () => { 134 | process.env.GLEAN_API_TOKEN = 'env-token'; 135 | process.env.GLEAN_INSTANCE = 'env-instance'; 136 | 137 | const result = await validateFlags( 138 | 'client', 139 | undefined, 140 | undefined, 141 | undefined, 142 | undefined, 143 | ); 144 | 145 | expect(result).toBe(true); 146 | expect(consoleState.getState('error')).toEqual(''); 147 | }); 148 | 149 | it('should return true when token and subdomain are available via environment variables', async () => { 150 | process.env.GLEAN_API_TOKEN = 'env-token'; 151 | process.env.GLEAN_SUBDOMAIN = 'env-subdomain'; 152 | 153 | const result = await validateFlags( 154 | 'client', 155 | undefined, 156 | undefined, 157 | undefined, 158 | undefined, 159 | ); 160 | 161 | expect(result).toBe(true); 162 | expect(consoleState.getState('error')).toEqual(''); 163 | }); 164 | 165 | it('should return true when token and base URL are available via environment variables', async () => { 166 | process.env.GLEAN_API_TOKEN = 'env-token'; 167 | process.env.GLEAN_BASE_URL = 'https://example.glean.com'; 168 | 169 | const result = await validateFlags( 170 | 'client', 171 | undefined, 172 | undefined, 173 | undefined, 174 | undefined, 175 | ); 176 | 177 | expect(result).toBe(true); 178 | expect(consoleState.getState('error')).toEqual(''); 179 | }); 180 | 181 | it('should return true but show warning when only token is available via environment', async () => { 182 | process.env.GLEAN_API_TOKEN = 'env-token'; 183 | delete process.env.GLEAN_INSTANCE; 184 | delete process.env.GLEAN_SUBDOMAIN; 185 | delete process.env.GLEAN_BASE_URL; 186 | 187 | const result = await validateFlags( 188 | 'client', 189 | undefined, 190 | undefined, 191 | undefined, 192 | undefined, 193 | ); 194 | 195 | expect(result).toBe(true); 196 | expect(consoleState.getState('error')).toMatchInlineSnapshot(` 197 | " 198 | "Warning: Configuring without complete credentials. 199 | You must provide either: 200 | 1. Both --token and --instance, or 201 | 2. --env pointing to a .env file containing GLEAN_API_TOKEN and GLEAN_INSTANCE 202 | 203 | Continuing with configuration, but you will need to set credentials manually later." 204 | " 205 | `); 206 | }); 207 | 208 | it('should return true when only instance is available via environment (OAuth flow)', async () => { 209 | delete process.env.GLEAN_API_TOKEN; 210 | process.env.GLEAN_INSTANCE = 'env-instance'; 211 | 212 | const result = await validateFlags( 213 | 'client', 214 | undefined, 215 | undefined, 216 | undefined, 217 | undefined, 218 | ); 219 | 220 | expect(result).toBe(true); 221 | expect(consoleState.getState('error')).toEqual(''); 222 | }); 223 | 224 | it('should return true when token flag is provided but instance comes from environment', async () => { 225 | process.env.GLEAN_INSTANCE = 'env-instance'; 226 | 227 | const result = await validateFlags( 228 | 'client', 229 | 'flag-token', 230 | undefined, 231 | undefined, 232 | undefined, 233 | ); 234 | 235 | expect(result).toBe(true); 236 | expect(consoleState.getState('error')).toEqual(''); 237 | }); 238 | 239 | it('should return true when instance flag is provided but token comes from environment', async () => { 240 | process.env.GLEAN_API_TOKEN = 'env-token'; 241 | 242 | const result = await validateFlags( 243 | 'client', 244 | undefined, 245 | 'flag-instance', 246 | undefined, 247 | undefined, 248 | ); 249 | 250 | expect(result).toBe(true); 251 | expect(consoleState.getState('error')).toEqual(''); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /packages/local-server/src/test/xdg/xdg.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import { 3 | getStateDir, 4 | ensureFileExistsWithLimitedPermissions, 5 | } from '../../xdg/xdg'; 6 | import os from 'node:os'; 7 | import fs from 'node:fs'; 8 | import path from 'node:path'; 9 | 10 | vi.mock('node:os'); 11 | vi.mock('node:fs'); 12 | 13 | describe('XDG Functions', () => { 14 | const originalEnv = process.env; 15 | const mockHomeDir = '/mock/home'; 16 | const testAppName = 'testapp'; 17 | 18 | beforeEach(() => { 19 | process.env = { ...originalEnv }; 20 | vi.resetAllMocks(); 21 | 22 | // Mock os functions 23 | vi.mocked(os.homedir).mockReturnValue(mockHomeDir); 24 | }); 25 | 26 | afterEach(() => { 27 | process.env = originalEnv; 28 | }); 29 | 30 | describe('getStateDir', () => { 31 | it('should use XDG_STATE_HOME when set', () => { 32 | const xdgStateHome = '/custom/state/home'; 33 | process.env.XDG_STATE_HOME = xdgStateHome; 34 | 35 | const result = getStateDir(testAppName); 36 | expect(result).toBe(path.join(xdgStateHome, testAppName)); 37 | }); 38 | 39 | it('should use default Unix-like path when XDG_STATE_HOME is not set on Unix', () => { 40 | vi.mocked(os.platform).mockReturnValue('darwin'); 41 | delete process.env.XDG_STATE_HOME; 42 | 43 | const result = getStateDir(testAppName); 44 | expect(result).toBe( 45 | path.join(mockHomeDir, '.local', 'state', testAppName), 46 | ); 47 | }); 48 | 49 | it('should use LOCALAPPDATA on Windows when available', () => { 50 | vi.mocked(os.platform).mockReturnValue('win32'); 51 | const mockLocalAppData = 'C:\\Users\\Test\\AppData\\Local'; 52 | process.env.LOCALAPPDATA = mockLocalAppData; 53 | 54 | const result = getStateDir(testAppName); 55 | expect(result).toBe(path.join(mockLocalAppData, 'state', testAppName)); 56 | }); 57 | 58 | it('should fallback to AppData\\Local on Windows when LOCALAPPDATA is not set', () => { 59 | vi.mocked(os.platform).mockReturnValue('win32'); 60 | delete process.env.LOCALAPPDATA; 61 | 62 | const result = getStateDir(testAppName); 63 | expect(result).toBe( 64 | path.join(mockHomeDir, 'AppData', 'Local', 'state', testAppName), 65 | ); 66 | }); 67 | }); 68 | 69 | describe('ensureFileExistsWithLimitedPermissions', () => { 70 | const testFilePath = '/test/path/file.txt'; 71 | 72 | beforeEach(() => { 73 | vi.mocked(fs.existsSync).mockReturnValue(false); 74 | vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); 75 | vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); 76 | vi.mocked(fs.chmodSync).mockImplementation(() => undefined); 77 | }); 78 | 79 | it('should create directory if it does not exist', () => { 80 | ensureFileExistsWithLimitedPermissions(testFilePath); 81 | 82 | expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(testFilePath), { 83 | recursive: true, 84 | }); 85 | }); 86 | 87 | it('should create file if it does not exist', () => { 88 | ensureFileExistsWithLimitedPermissions(testFilePath); 89 | 90 | expect(fs.writeFileSync).toHaveBeenCalledWith(testFilePath, '', { 91 | encoding: 'utf8', 92 | }); 93 | }); 94 | 95 | it('should not create file if it already exists', () => { 96 | vi.mocked(fs.existsSync).mockReturnValue(true); 97 | 98 | ensureFileExistsWithLimitedPermissions(testFilePath); 99 | 100 | expect(fs.writeFileSync).not.toHaveBeenCalled(); 101 | }); 102 | 103 | it('should set file permissions to 0o600', () => { 104 | ensureFileExistsWithLimitedPermissions(testFilePath); 105 | 106 | expect(fs.chmodSync).toHaveBeenCalledWith(testFilePath, 0o600); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /packages/local-server/src/tools/chat.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { getClient } from '../common/client.js'; 3 | import { 4 | ChatRequest, 5 | ChatRequest$inboundSchema as ChatRequestSchema, 6 | MessageType, 7 | } from '@gleanwork/api-client/models/components'; 8 | import { Author } from '@gleanwork/api-client/models/components'; 9 | 10 | /** 11 | * Simplified schema for Glean chat requests designed for LLM interaction 12 | */ 13 | export const ToolChatSchema = z.object({ 14 | message: z 15 | .string() 16 | .describe('The user question or message to send to Glean Assistant.'), 17 | 18 | context: z 19 | .array(z.string()) 20 | .describe( 21 | 'Optional previous messages for context. Will be included in order before the current message.', 22 | ) 23 | .optional(), 24 | }); 25 | 26 | export type ToolChatRequest = z.infer; 27 | 28 | /** 29 | * Maps a simplified chat request to the format expected by the Glean API. 30 | * 31 | * @param input Simplified chat request parameters 32 | * @returns Glean API compatible chat request 33 | */ 34 | function convertToAPIChatRequest(input: ToolChatRequest) { 35 | const { message, context = [] } = input; 36 | 37 | const messages = [ 38 | ...context.map((text) => ({ 39 | author: Author.User, 40 | messageType: MessageType.Content, 41 | fragments: [{ text }], 42 | })), 43 | 44 | { 45 | author: Author.User, 46 | messageType: MessageType.Content, 47 | fragments: [{ text: message }], 48 | }, 49 | ]; 50 | 51 | const chatRequest: ChatRequest = { 52 | messages, 53 | }; 54 | 55 | return chatRequest; 56 | } 57 | 58 | /** 59 | * Initiates or continues a chat conversation with Glean's AI. 60 | * 61 | * @param params The chat parameters using the simplified schema 62 | * @returns The chat response 63 | * @throws If the chat request fails 64 | */ 65 | export async function chat(params: ToolChatRequest) { 66 | const mappedParams = convertToAPIChatRequest(params); 67 | const parsedParams = ChatRequestSchema.parse(mappedParams); 68 | const client = await getClient(); 69 | 70 | return await client.chat.create(parsedParams); 71 | } 72 | 73 | /** 74 | * Formats chat responses into a human-readable text format. 75 | * 76 | * @param chatResponse The raw chat response from Glean API 77 | * @returns Formatted chat response as text 78 | */ 79 | export function formatResponse(chatResponse: any): string { 80 | if ( 81 | !chatResponse || 82 | !chatResponse.messages || 83 | !Array.isArray(chatResponse.messages) || 84 | chatResponse.messages.length === 0 85 | ) { 86 | return 'No response received.'; 87 | } 88 | 89 | const formattedMessages = chatResponse.messages 90 | .map((message: any) => { 91 | const author = message.author || 'Unknown'; 92 | 93 | let messageText = ''; 94 | 95 | if (message.fragments && Array.isArray(message.fragments)) { 96 | messageText = message.fragments 97 | .map((fragment: any) => { 98 | if (fragment.text) { 99 | return fragment.text; 100 | } else if (fragment.querySuggestion) { 101 | return `Query: ${fragment.querySuggestion.query}`; 102 | } else if ( 103 | fragment.structuredResults && 104 | Array.isArray(fragment.structuredResults) 105 | ) { 106 | return fragment.structuredResults 107 | .map((result: any) => { 108 | if (result.document) { 109 | const doc = result.document; 110 | 111 | return `Document: ${doc.title || 'Untitled'} (${ 112 | doc.url || 'No URL' 113 | })`; 114 | } 115 | 116 | return ''; 117 | }) 118 | .filter(Boolean) 119 | .join('\n'); 120 | } 121 | 122 | return ''; 123 | }) 124 | .filter(Boolean) 125 | .join('\n'); 126 | } 127 | 128 | let citationsText = ''; 129 | if ( 130 | message.citations && 131 | Array.isArray(message.citations) && 132 | message.citations.length > 0 133 | ) { 134 | citationsText = 135 | '\n\nSources:\n' + 136 | message.citations 137 | .map((citation: any, index: number) => { 138 | const sourceDoc = citation.sourceDocument || {}; 139 | const title = sourceDoc.title || 'Unknown source'; 140 | const url = sourceDoc.url || ''; 141 | return `[${index + 1}] ${title} - ${url}`; 142 | }) 143 | .join('\n'); 144 | } 145 | 146 | const messageType = message.messageType 147 | ? ` (${message.messageType})` 148 | : ''; 149 | const stepId = message.stepId ? ` [Step: ${message.stepId}]` : ''; 150 | 151 | return `${author}${messageType}${stepId}: ${messageText}${citationsText}`; 152 | }) 153 | .join('\n\n'); 154 | 155 | return formattedMessages; 156 | } 157 | -------------------------------------------------------------------------------- /packages/local-server/src/tools/people_profile_search.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { getClient } from '../common/client.js'; 3 | import { 4 | ListEntitiesRequest, 5 | ListEntitiesRequestEntityType, 6 | ListEntitiesRequest$inboundSchema as ListEntitiesRequestSchema, 7 | } from '@gleanwork/api-client/models/components'; 8 | 9 | // Allowed facet names for filtering people searches. 10 | export const PEOPLE_FACETS = z.enum([ 11 | 'email', 12 | 'first_name', 13 | 'last_name', 14 | 'manager_email', 15 | 'department', 16 | 'title', 17 | 'location', 18 | 'city', 19 | 'country', 20 | 'state', 21 | 'region', 22 | 'business_unit', 23 | 'team', 24 | 'team_id', 25 | 'nickname', 26 | 'preferred_name', 27 | 'roletype', 28 | 'reportsto', 29 | 'startafter', 30 | 'startbefore', 31 | 'industry', 32 | 'has', 33 | 'from', 34 | ]); 35 | 36 | /** 37 | * Simplified schema for people profile search requests designed for LLM interaction 38 | */ 39 | export const ToolPeopleProfileSearchSchema = z 40 | .object({ 41 | query: z 42 | .string() 43 | .describe('Free-text query to search people by name, title, etc.') 44 | .optional(), 45 | 46 | filters: z 47 | .record(PEOPLE_FACETS, z.string()) 48 | .describe( 49 | 'Allowed facet fields: email, first_name, last_name, manager_email, department, title, location, city, country, state, region, business_unit, team, team_id, nickname, preferred_name, roletype, reportsto, startafter, startbefore, industry, has, from. Provide as { "facet": "value" }.', 50 | ) 51 | .optional(), 52 | 53 | pageSize: z 54 | .number() 55 | .int() 56 | .min(1) 57 | .max(100) 58 | .describe( 59 | 'Hint to the server for how many people to return (1-100, default 10).', 60 | ) 61 | .optional(), 62 | }) 63 | .refine( 64 | (val) => val.query || (val.filters && Object.keys(val.filters).length > 0), 65 | { 66 | message: 'At least one of "query" or "filters" must be provided.', 67 | path: [], 68 | }, 69 | ); 70 | 71 | export type ToolPeopleProfileSearchRequest = z.infer< 72 | typeof ToolPeopleProfileSearchSchema 73 | >; 74 | 75 | /** 76 | * Converts a simplified request to a Glean API ListEntitiesRequest. 77 | * 78 | * @param input The simplified request parameters 79 | * @returns The Glean API compatible request 80 | */ 81 | function convertToAPIEntitiesRequest(input: ToolPeopleProfileSearchRequest) { 82 | const { query, filters = {}, pageSize } = input; 83 | 84 | const request: ListEntitiesRequest = { 85 | entityType: ListEntitiesRequestEntityType.People, 86 | pageSize: pageSize || 10, 87 | }; 88 | 89 | if (query) { 90 | request.query = query; 91 | } 92 | 93 | const filterKeys = Object.keys(filters) as Array; 94 | if (filterKeys.length > 0) { 95 | request.filter = filterKeys.map((fieldName) => { 96 | const value = filters[fieldName]; 97 | return { 98 | fieldName: String(fieldName), 99 | values: [ 100 | { 101 | relationType: 'EQUALS', 102 | value: value as string, 103 | }, 104 | ], 105 | }; 106 | }); 107 | } 108 | 109 | return request; 110 | } 111 | 112 | /** 113 | * Executes a people profile search using the Glean API. 114 | * 115 | * @param params The search parameters using the simplified schema 116 | * @returns The search results 117 | */ 118 | export async function peopleProfileSearch( 119 | params: ToolPeopleProfileSearchRequest, 120 | ) { 121 | const mappedParams = convertToAPIEntitiesRequest(params); 122 | const parsedParams = ListEntitiesRequestSchema.parse(mappedParams); 123 | const client = await getClient(); 124 | 125 | return await client.entities.list(parsedParams); 126 | } 127 | 128 | /** 129 | * Formats the search results for human consumption. 130 | * 131 | * @param searchResults The raw search results from Glean API 132 | * @returns Formatted search results as text 133 | */ 134 | export function formatResponse(searchResults: any): string { 135 | if ( 136 | !searchResults || 137 | !Array.isArray(searchResults.results) || 138 | searchResults.results.length === 0 139 | ) { 140 | return 'No matching people found.'; 141 | } 142 | 143 | const formatted = searchResults.results 144 | .map((person: any, index: number) => { 145 | const metadata = person.metadata ?? {}; 146 | 147 | const displayName = metadata.preferredName || person.name || 'Unnamed'; 148 | 149 | const title = metadata.title || 'Unknown title'; 150 | 151 | const department = metadata.department || 'Unknown department'; 152 | 153 | const location = 154 | metadata.location || 155 | metadata.structuredLocation?.city || 156 | metadata.structuredLocation?.country || 157 | 'Unknown location'; 158 | 159 | const email = 160 | metadata.email || metadata.aliasEmails?.[0] || 'Unknown email'; 161 | 162 | // Show first team affiliation if present for additional context 163 | const primaryTeam = 164 | Array.isArray(metadata.teams) && metadata.teams.length > 0 165 | ? metadata.teams[0].name 166 | : undefined; 167 | 168 | const teamSuffix = primaryTeam ? ` [${primaryTeam}]` : ''; 169 | 170 | return `${index + 1}. ${displayName} – ${title}${teamSuffix}, ${department} (${location}) • ${email}`; 171 | }) 172 | .join('\n'); 173 | 174 | const total = 175 | typeof searchResults.totalCount === 'number' 176 | ? searchResults.totalCount 177 | : searchResults.results.length; 178 | 179 | return `Found ${total} people:\n\n${formatted}`; 180 | } 181 | -------------------------------------------------------------------------------- /packages/local-server/src/tools/search.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Search tool implementation for the Glean MCP server. 3 | * 4 | * This module provides a search interface to Glean's content index through the MCP protocol. 5 | * It defines the schema for search parameters and implements the search functionality using 6 | * the Glean client SDK. 7 | * 8 | * @module tools/search 9 | */ 10 | 11 | import { z } from 'zod'; 12 | import { getClient } from '../common/client.js'; 13 | import { 14 | SearchRequest, 15 | SearchRequest$inboundSchema as SearchRequestSchema, 16 | } from '@gleanwork/api-client/models/components'; 17 | 18 | /** 19 | * Simplified schema for Glean search requests designed for LLM interaction 20 | */ 21 | export const ToolSearchSchema = z.object({ 22 | query: z 23 | .string() 24 | .describe('The search query. This is what you want to search for.'), 25 | 26 | datasources: z 27 | .array(z.string()) 28 | .describe( 29 | 'Optional list of data sources to search in. Examples: "github", "gdrive", "confluence", "jira".', 30 | ) 31 | .optional(), 32 | }); 33 | 34 | export type ToolSearchRequest = z.infer; 35 | 36 | /** 37 | * Maps a simplified search request to the format expected by the Glean API. 38 | * 39 | * @param input Simplified search request parameters 40 | * @returns Glean API compatible search request 41 | */ 42 | function convertToAPISearchRequest(input: ToolSearchRequest) { 43 | const { query, datasources } = input; 44 | 45 | const searchRequest: SearchRequest = { 46 | query, 47 | pageSize: 10, 48 | }; 49 | 50 | if (datasources && datasources.length > 0) { 51 | searchRequest.requestOptions = { 52 | datasourcesFilter: datasources, 53 | facetBucketSize: 10, 54 | }; 55 | } 56 | 57 | return searchRequest; 58 | } 59 | 60 | /** 61 | * Executes a search query against Glean's content index. 62 | * 63 | * @param params The search parameters using the simplified schema 64 | * @returns The search results 65 | * @throws If the search request fails 66 | */ 67 | export async function search(params: ToolSearchRequest) { 68 | const mappedParams = convertToAPISearchRequest(params); 69 | const parsedParams = SearchRequestSchema.parse(mappedParams); 70 | const client = await getClient(); 71 | 72 | return await client.search.query(parsedParams); 73 | } 74 | 75 | /** 76 | * Formats search results into a human-readable text format. 77 | * 78 | * @param searchResults The raw search results from Glean API 79 | * @returns Formatted search results as text 80 | */ 81 | export function formatResponse(searchResults: any): string { 82 | if ( 83 | !searchResults || 84 | !searchResults.results || 85 | !Array.isArray(searchResults.results) 86 | ) { 87 | return 'No results found.'; 88 | } 89 | 90 | const formattedResults = searchResults.results 91 | .map((result: any, index: number) => { 92 | const title = result.title || 'No title'; 93 | const url = result.url || ''; 94 | const document = result.document || {}; 95 | 96 | let snippetText = ''; 97 | if (result.snippets && Array.isArray(result.snippets)) { 98 | const sortedSnippets = [...result.snippets].sort((a, b) => { 99 | const orderA = a.snippetTextOrdering || 0; 100 | const orderB = b.snippetTextOrdering || 0; 101 | return orderA - orderB; 102 | }); 103 | 104 | snippetText = sortedSnippets 105 | .map((snippet) => snippet.text || '') 106 | .filter(Boolean) 107 | .join('\n'); 108 | } 109 | 110 | if (!snippetText) { 111 | snippetText = 'No description available'; 112 | } 113 | 114 | return `[${index + 1}] ${title}\n${snippetText}\nSource: ${ 115 | document.datasource || 'Unknown source' 116 | }\nURL: ${url}`; 117 | }) 118 | .join('\n\n'); 119 | 120 | const totalResults = 121 | searchResults.totalResults || searchResults.results.length; 122 | const query = searchResults.metadata.searchedQuery || 'your query'; 123 | 124 | return `Search results for "${query}" (${totalResults} results):\n\n${formattedResults}`; 125 | } 126 | -------------------------------------------------------------------------------- /packages/local-server/src/types/index.ts: -------------------------------------------------------------------------------- 1 | /** @file Typescript typing information 2 | * If you want to break your types out from your code, you can 3 | * place them into the 'types' folder. Note that if you using 4 | * the type declaration extention ('.d.ts') your files will not 5 | * be compiled -- if you need to deliver your types to consumers 6 | * of a published npm module use the '.ts' extension instead. 7 | */ 8 | -------------------------------------------------------------------------------- /packages/local-server/src/util/object.ts: -------------------------------------------------------------------------------- 1 | export function stripUndefined>(obj: T): Partial { 2 | return Object.fromEntries( 3 | Object.entries(obj).filter(([_, v]) => v !== undefined), 4 | ) as Partial; 5 | } 6 | -------------------------------------------------------------------------------- /packages/local-server/src/util/preflight.ts: -------------------------------------------------------------------------------- 1 | import { trace, error } from '../log/logger.js'; 2 | 3 | /** 4 | * Validates that the given instance name is valid by checking its liveness endpoint. 5 | * Makes a fetch request to https://{instance}-be.glean.com/liveness_check 6 | * 7 | * @param instance - The instance name to validate 8 | * @returns A Promise that resolves to true if the instance is valid 9 | */ 10 | export async function validateInstance(instance: string): Promise { 11 | if (!instance) { 12 | trace('No instance provided for validation'); 13 | return false; 14 | } 15 | 16 | try { 17 | const url = `https://${instance}-be.glean.com/liveness_check`; 18 | trace(`Checking instance validity with: ${url}`); 19 | 20 | const response = await fetch(url, { 21 | method: 'GET', 22 | headers: { 23 | Accept: 'application/json', 24 | }, 25 | }); 26 | 27 | // We only care that the request succeeds, not about the response content 28 | if (!response.ok) { 29 | error( 30 | `Instance validation failed for ${instance}: ${response.status} ${response.statusText}`, 31 | ); 32 | return false; 33 | } 34 | 35 | return true; 36 | } catch (err) { 37 | const cause = err instanceof Error ? err : new Error(String(err)); 38 | 39 | error(`Instance validation failed: ${cause.message}`); 40 | return false; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/local-server/src/xdg/xdg.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import os from 'node:os'; 3 | import fs from 'node:fs'; 4 | 5 | export function getStateDir(name: string) { 6 | const platform = os.platform(); 7 | const homeDir = os.homedir(); 8 | 9 | // Check for XDG_STATE_HOME first 10 | const xdgStateHome = process.env.XDG_STATE_HOME; 11 | if (xdgStateHome) { 12 | return path.join(xdgStateHome, name); 13 | } 14 | 15 | // Platform-specific defaults 16 | if (platform === 'win32') { 17 | // Windows: %LOCALAPPDATA%\state\{name} 18 | const localAppData = 19 | process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); 20 | return path.join(localAppData, 'state', name); 21 | } 22 | 23 | // Unix-like (Linux, macOS, etc): ~/.local/state/{name} 24 | return path.join(homeDir, '.local', 'state', name); 25 | } 26 | 27 | export function ensureFileExistsWithLimitedPermissions(filePath: string) { 28 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); 29 | 30 | if (!fs.existsSync(filePath)) { 31 | fs.writeFileSync(filePath, '', { encoding: 'utf8' }); 32 | } 33 | 34 | fs.chmodSync(filePath, 0o600); 35 | } 36 | -------------------------------------------------------------------------------- /packages/local-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "target": "es2017", 6 | "module": "esnext", 7 | "lib": ["es2022"], 8 | "outDir": "./build", 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "moduleResolution": "bundler", 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "strict": true, 16 | "checkJs": true, 17 | "allowJs": true, 18 | "skipLibCheck": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["src/lib/*"], 22 | "$/*": ["src/*"], 23 | "$cli/*": ["src/cli/*"], 24 | "$test/*": ["src/test/*"] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/local-server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['src/test/**/*.test.ts'], 6 | exclude: ['**/node_modules/**', '**/build/**'], 7 | environment: 'node', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | --------------------------------------------------------------------------------