wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation

This commit is contained in:
2026-03-29 22:08:40 +09:00
parent aca7bf592a
commit 2507de45d3
4289 changed files with 732689 additions and 28672 deletions

View File

@@ -0,0 +1 @@
package-lock.json linguist-generated=true

View File

@@ -0,0 +1,44 @@
<!-- Provide a brief description of your changes -->
## Description
## Publishing Your Server
**Note: We are no longer accepting PRs to add servers to the README.** Instead, please publish your server to the [MCP Server Registry](https://github.com/modelcontextprotocol/registry) to make it discoverable to the MCP ecosystem.
To publish your server, follow the [quickstart guide](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx). You can browse published servers at [https://registry.modelcontextprotocol.io/](https://registry.modelcontextprotocol.io/).
## Server Details
<!-- If modifying an existing server, provide details -->
- Server: <!-- e.g., filesystem, github -->
- Changes to: <!-- e.g., tools, resources, prompts -->
## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
## How Has This Been Tested?
<!-- Have you tested this with an LLM client? Which scenarios were tested? -->
## Breaking Changes
<!-- Will users need to update their MCP client configurations? -->
## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [ ] I have read the [MCP Protocol Documentation](https://modelcontextprotocol.io)
- [ ] My changes follows MCP security best practices
- [ ] I have updated the server's README accordingly
- [ ] I have tested this with an LLM client
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have documented all environment variables and configuration options
## Additional context
<!-- Add any other context, implementation notes, or design decisions -->

View File

@@ -0,0 +1,49 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Allow Claude to read CI results on PRs
additional_permissions: |
actions: read
# Trigger when assigned to an issue
assignee_trigger: "claude"
claude_args: |
--mcp-config .mcp.json
--allowedTools "Bash,mcp__mcp-docs,WebFetch"
--append-system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block. When working on MCP-related code or reviewing MCP-related changes, use the mcp-docs MCP server to look up the latest protocol documentation. For schema details, reference https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema which contains versioned schemas in JSON (schema.json) and TypeScript (schema.ts) formats."

View File

@@ -0,0 +1,121 @@
name: Python
on:
push:
branches:
- main
pull_request:
release:
types: [published]
jobs:
detect-packages:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.find-packages.outputs.packages }}
steps:
- uses: actions/checkout@v6
- name: Find Python packages
id: find-packages
working-directory: src
run: |
PACKAGES=$(find . -name pyproject.toml -exec dirname {} \; | sed 's/^\.\///' | jq -R -s -c 'split("\n")[:-1]')
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
test:
needs: [detect-packages]
strategy:
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
name: Test ${{ matrix.package }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: "src/${{ matrix.package }}/.python-version"
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: uv sync --frozen --all-extras --dev
- name: Check if tests exist
id: check-tests
working-directory: src/${{ matrix.package }}
run: |
if [ -d "tests" ] || [ -d "test" ] || grep -q "pytest" pyproject.toml; then
echo "has-tests=true" >> $GITHUB_OUTPUT
else
echo "has-tests=false" >> $GITHUB_OUTPUT
fi
- name: Run tests
if: steps.check-tests.outputs.has-tests == 'true'
working-directory: src/${{ matrix.package }}
run: uv run pytest
build:
needs: [detect-packages, test]
strategy:
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
name: Build ${{ matrix.package }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: "src/${{ matrix.package }}/.python-version"
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: uv sync --locked --all-extras --dev
- name: Run pyright
working-directory: src/${{ matrix.package }}
run: uv run --frozen pyright
- name: Build package
working-directory: src/${{ matrix.package }}
run: uv build
- name: Upload artifacts
uses: actions/upload-artifact@v6
with:
name: dist-${{ matrix.package }}
path: src/${{ matrix.package }}/dist/
publish:
runs-on: ubuntu-latest
needs: [build, detect-packages]
if: github.event_name == 'release'
strategy:
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
name: Publish ${{ matrix.package }}
environment: release
permissions:
id-token: write # Required for trusted publishing
steps:
- name: Download artifacts
uses: actions/download-artifact@v7
with:
name: dist-${{ matrix.package }}
path: dist/
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

View File

@@ -0,0 +1,111 @@
name: README PR Check
on:
pull_request:
types: [opened]
paths:
- 'README.md'
issue_comment:
types: [created]
jobs:
check-readme-only:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check files and comment if README-only
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const { data: files } = await github.rest.pulls.listFiles({ owner, repo, pull_number: prNumber });
if (files.length !== 1 || files[0].filename !== 'README.md') {
console.log('PR modifies files other than README, skipping');
return;
}
// Check if we've already commented
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber });
if (comments.some(c => c.user.login === 'github-actions[bot]' && c.body.includes('no longer accepting PRs to add new servers'))) {
console.log('Already commented on this PR, skipping');
return;
}
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ['readme: pending'] });
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: [
'Thanks for your contribution!',
'',
'**We are no longer accepting PRs to add new servers to the README.** The server lists are deprecated and will eventually be removed entirely, replaced by the registry.',
'',
'👉 **To add a new MCP server:** Please publish it to the [MCP Server Registry](https://github.com/modelcontextprotocol/registry) instead. You can browse published servers at [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io/).',
'',
'👉 **If this PR updates or removes an existing entry:** We do still accept these changes. Please reply with `/i-promise-this-is-not-a-new-server` to continue.',
'',
'If this PR is adding a new server, please close it and submit to the registry instead.',
].join('\n'),
});
handle-confirmation:
if: github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/i-promise-this-is-not-a-new-server')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Swap labels and minimize comments
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.issue.number;
// Check if pending label exists
const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: prNumber });
if (!labels.some(l => l.name === 'readme: pending')) {
console.log('No pending label found, skipping');
return;
}
// Swap labels
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'readme: pending' });
} catch (e) {}
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ['readme: ready for review'] });
// Find the bot's original comment
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber });
const botComment = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('no longer accepting PRs to add new servers')
);
// Minimize both comments via GraphQL
const minimizeComment = async (nodeId) => {
await github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: RESOLVED}) {
minimizedComment { isMinimized }
}
}
`, { id: nodeId });
};
if (botComment) {
await minimizeComment(botComment.node_id);
}
// Only minimize user's comment if it's just the command
const userComment = context.payload.comment.body.trim();
if (userComment === '/i-promise-this-is-not-a-new-server') {
await minimizeComment(context.payload.comment.node_id);
}

View File

@@ -0,0 +1,222 @@
name: Automatic Release Creation
on:
workflow_dispatch:
schedule:
- cron: '0 10 * * *'
jobs:
create-metadata:
runs-on: ubuntu-latest
if: github.repository_owner == 'modelcontextprotocol'
outputs:
hash: ${{ steps.last-release.outputs.hash }}
version: ${{ steps.create-version.outputs.version}}
npm_packages: ${{ steps.create-npm-packages.outputs.npm_packages}}
pypi_packages: ${{ steps.create-pypi-packages.outputs.pypi_packages}}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get last release hash
id: last-release
run: |
HASH=$(git rev-list --tags --max-count=1 || echo "HEAD~1")
echo "hash=${HASH}" >> $GITHUB_OUTPUT
echo "Using last release hash: ${HASH}"
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Create version name
id: create-version
run: |
VERSION=$(uv run --script scripts/release.py generate-version)
echo "version $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create notes
run: |
HASH="${{ steps.last-release.outputs.hash }}"
uv run --script scripts/release.py generate-notes --directory src/ $HASH > RELEASE_NOTES.md
cat RELEASE_NOTES.md
- name: Release notes
uses: actions/upload-artifact@v6
with:
name: release-notes
path: RELEASE_NOTES.md
- name: Create python matrix
id: create-pypi-packages
run: |
HASH="${{ steps.last-release.outputs.hash }}"
PYPI=$(uv run --script scripts/release.py generate-matrix --pypi --directory src $HASH)
echo "pypi_packages $PYPI"
echo "pypi_packages=$PYPI" >> $GITHUB_OUTPUT
- name: Create npm matrix
id: create-npm-packages
run: |
HASH="${{ steps.last-release.outputs.hash }}"
NPM=$(uv run --script scripts/release.py generate-matrix --npm --directory src $HASH)
echo "npm_packages $NPM"
echo "npm_packages=$NPM" >> $GITHUB_OUTPUT
update-packages:
needs: [create-metadata]
if: ${{ needs.create-metadata.outputs.npm_packages != '[]' || needs.create-metadata.outputs.pypi_packages != '[]' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
outputs:
changes_made: ${{ steps.commit.outputs.changes_made }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Update packages
run: |
HASH="${{ needs.create-metadata.outputs.hash }}"
uv run --script scripts/release.py update-packages --directory src/ $HASH
- name: Configure git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
- name: Commit changes
id: commit
run: |
VERSION="${{ needs.create-metadata.outputs.version }}"
git add -u
if git diff-index --quiet HEAD; then
echo "changes_made=false" >> $GITHUB_OUTPUT
else
git commit -m 'Automatic update of packages'
git tag -a "$VERSION" -m "Release $VERSION"
git push origin "$VERSION"
echo "changes_made=true" >> $GITHUB_OUTPUT
fi
publish-pypi:
needs: [update-packages, create-metadata]
if: ${{ needs.create-metadata.outputs.pypi_packages != '[]' && needs.create-metadata.outputs.pypi_packages != '' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.create-metadata.outputs.pypi_packages) }}
name: Build ${{ matrix.package }}
environment: release
permissions:
id-token: write # Required for trusted publishing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.create-metadata.outputs.version }}
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: "src/${{ matrix.package }}/.python-version"
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: uv sync --frozen --all-extras --dev
- name: Run pyright
working-directory: src/${{ matrix.package }}
run: uv run --frozen pyright
- name: Build package
working-directory: src/${{ matrix.package }}
run: uv build
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: src/${{ matrix.package }}/dist
publish-npm:
needs: [update-packages, create-metadata]
if: ${{ needs.create-metadata.outputs.npm_packages != '[]' && needs.create-metadata.outputs.npm_packages != '' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.create-metadata.outputs.npm_packages) }}
name: Build ${{ matrix.package }}
environment: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.create-metadata.outputs.version }}
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: npm ci
- name: Check if version exists on npm
working-directory: src/${{ matrix.package }}
run: |
VERSION=$(jq -r .version package.json)
if npm view --json | jq -e --arg version "$VERSION" '[.[]][0].versions | contains([$version])'; then
echo "Version $VERSION already exists on npm"
exit 1
fi
echo "Version $VERSION is new, proceeding with publish"
- name: Build package
working-directory: src/${{ matrix.package }}
run: npm run build
- name: Publish package
working-directory: src/${{ matrix.package }}
run: |
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
create-release:
needs: [update-packages, create-metadata, publish-pypi, publish-npm]
if: |
always() &&
needs.update-packages.outputs.changes_made == 'true' &&
(needs.publish-pypi.result == 'success' || needs.publish-npm.result == 'success')
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Download release notes
uses: actions/download-artifact@v7
with:
name: release-notes
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN}}
run: |
VERSION="${{ needs.create-metadata.outputs.version }}"
gh release create "$VERSION" \
--title "Release $VERSION" \
--notes-file RELEASE_NOTES.md

View File

@@ -0,0 +1,102 @@
name: TypeScript
on:
push:
branches:
- main
pull_request:
release:
types: [published]
jobs:
detect-packages:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.find-packages.outputs.packages }}
steps:
- uses: actions/checkout@v6
- name: Find JS packages
id: find-packages
working-directory: src
run: |
PACKAGES=$(find . -name package.json -not -path "*/node_modules/*" -exec dirname {} \; | sed 's/^\.\///' | jq -R -s -c 'split("\n")[:-1]')
echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
test:
needs: [detect-packages]
strategy:
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
name: Test ${{ matrix.package }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: npm ci
- name: Run tests
working-directory: src/${{ matrix.package }}
run: npm test --if-present
build:
needs: [detect-packages, test]
strategy:
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
name: Build ${{ matrix.package }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: npm ci
- name: Build package
working-directory: src/${{ matrix.package }}
run: npm run build
publish:
runs-on: ubuntu-latest
needs: [build, detect-packages]
if: github.event_name == 'release'
environment: release
strategy:
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
name: Publish ${{ matrix.package }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
working-directory: src/${{ matrix.package }}
run: npm ci
- name: Publish package
working-directory: src/${{ matrix.package }}
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

305
.agent/services/mcp-core/.gitignore vendored Normal file
View File

@@ -0,0 +1,305 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# IDEs
.idea/
.vscode/
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
build/
gcp-oauth.keys.json
.*-server-credentials.json
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
.DS_Store
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.claude/settings.local.json

View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"mcp-docs": {
"type": "http",
"url": "https://modelcontextprotocol.io/mcp"
}
}
}

View File

@@ -0,0 +1,2 @@
registry="https://registry.npmjs.org/"
@modelcontextprotocol:registry="https://registry.npmjs.org/"

View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
mcp-coc@anthropic.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -0,0 +1,40 @@
# Contributing to MCP Servers
Thanks for your interest in contributing! Here's how you can help make this repo better.
We accept changes through [the standard GitHub flow model](https://docs.github.com/en/get-started/using-github/github-flow).
## Server Listings
We are **no longer accepting PRs** to add server links to the README. Please publish your server to the [MCP Server Registry](https://github.com/modelcontextprotocol/registry) instead. Follow the [quickstart guide](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx).
You can browse published servers using the simple UI at [https://registry.modelcontextprotocol.io/](https://registry.modelcontextprotocol.io/).
## Server Implementations
We welcome:
- **Bug fixes** — Help us squash those pesky bugs.
- **Usability improvements** — Making servers easier to use for humans and agents.
- **Enhancements that demonstrate MCP protocol features** — We encourage contributions that help reference servers better illustrate underutilized aspects of the MCP protocol beyond just Tools, such as Resources, Prompts, or Roots. For example, adding Roots support to filesystem-server helps showcase this important but lesser-known feature.
We're more selective about:
- **Other new features** — Especially if they're not crucial to the server's core purpose or are highly opinionated. The existing servers are reference servers meant to inspire the community. If you need specific features, we encourage you to build enhanced versions and publish them to the [MCP Server Registry](https://github.com/modelcontextprotocol/registry)! We think a diverse ecosystem of servers is beneficial for everyone.
We don't accept:
- **New server implementations** — We encourage you to publish them to the [MCP Server Registry](https://github.com/modelcontextprotocol/registry) instead.
## Testing
When adding or configuring tests for servers implemented in TypeScript, use **vitest** as the test framework. Vitest provides better ESM support, faster test execution, and a more modern testing experience.
## Documentation
Improvements to existing documentation is welcome - although generally we'd prefer ergonomic improvements than documenting pain points if possible!
We're more selective about adding wholly new documentation, especially in ways that aren't vendor neutral (e.g. how to run a particular server with a particular client).
## Community
[Learn how the MCP community communicates](https://modelcontextprotocol.io/community/communication).
Thank you for helping make MCP servers better for everyone!

View File

@@ -0,0 +1,216 @@
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
# Security Policy
Thank you for helping keep the Model Context Protocol and its ecosystem secure.
## Important Notice
The servers in this repository are **reference implementations** intended to demonstrate
MCP features and SDK usage. They serve as educational examples for developers building
their own MCP servers, not as production-ready solutions.
This repository is **not** eligible for security vulnerability reporting. If you discover
a vulnerability in an MCP SDK, please report it in the appropriate SDK repository.
## Reporting Security Issues in MCP SDKs
If you discover a security vulnerability in an MCP SDK, please report it through the
[GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
in the relevant SDK repository.
Please **do not** report security vulnerabilities through public GitHub issues, discussions,
or pull requests.

4044
.agent/services/mcp-core/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "@modelcontextprotocol/servers",
"private": true,
"version": "0.6.2",
"description": "Model Context Protocol servers",
"license": "SEE LICENSE IN LICENSE",
"author": "Model Context Protocol a Series of LF Projects, LLC.",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/servers/issues",
"type": "module",
"workspaces": [
"src/*"
],
"files": [],
"scripts": {
"build": "npm run build --workspaces",
"watch": "npm run watch --workspaces",
"publish-all": "npm publish --workspaces --access public",
"link-all": "npm link --workspaces"
},
"dependencies": {
"@modelcontextprotocol/server-everything": "*",
"@modelcontextprotocol/server-memory": "*",
"@modelcontextprotocol/server-filesystem": "*",
"@modelcontextprotocol/server-sequential-thinking": "*"
}
}

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "click>=8.1.8",
# "tomlkit>=0.13.2"
# ]
# ///
import sys
import re
import click
from pathlib import Path
import json
import tomlkit
import datetime
import subprocess
from dataclasses import dataclass
from typing import Any, Iterator, NewType, Protocol
Version = NewType("Version", str)
GitHash = NewType("GitHash", str)
class GitHashParamType(click.ParamType):
name = "git_hash"
def convert(
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
) -> GitHash | None:
if value is None:
return None
if not (8 <= len(value) <= 40):
self.fail(f"Git hash must be between 8 and 40 characters, got {len(value)}")
if not re.match(r"^[0-9a-fA-F]+$", value):
self.fail("Git hash must contain only hex digits (0-9, a-f)")
try:
# Verify hash exists in repo
subprocess.run(
["git", "rev-parse", "--verify", value], check=True, capture_output=True
)
except subprocess.CalledProcessError:
self.fail(f"Git hash {value} not found in repository")
return GitHash(value.lower())
GIT_HASH = GitHashParamType()
class Package(Protocol):
path: Path
def package_name(self) -> str: ...
def update_version(self, version: Version) -> None: ...
@dataclass
class NpmPackage:
path: Path
def package_name(self) -> str:
with open(self.path / "package.json", "r") as f:
return json.load(f)["name"]
def update_version(self, version: Version):
with open(self.path / "package.json", "r+") as f:
data = json.load(f)
data["version"] = version
f.seek(0)
json.dump(data, f, indent=2)
f.truncate()
@dataclass
class PyPiPackage:
path: Path
def package_name(self) -> str:
with open(self.path / "pyproject.toml") as f:
toml_data = tomlkit.parse(f.read())
name = toml_data.get("project", {}).get("name")
if not name:
raise Exception("No name in pyproject.toml project section")
return str(name)
def update_version(self, version: Version):
# Update version in pyproject.toml
with open(self.path / "pyproject.toml") as f:
data = tomlkit.parse(f.read())
data["project"]["version"] = version
with open(self.path / "pyproject.toml", "w") as f:
f.write(tomlkit.dumps(data))
# Regenerate uv.lock to match the updated pyproject.toml
subprocess.run(["uv", "lock"], cwd=self.path, check=True)
def has_changes(path: Path, git_hash: GitHash) -> bool:
"""Check if any files changed between current state and git hash"""
try:
output = subprocess.run(
["git", "diff", "--name-only", git_hash, "--", "."],
cwd=path,
check=True,
capture_output=True,
text=True,
)
changed_files = [Path(f) for f in output.stdout.splitlines()]
relevant_files = [f for f in changed_files if f.suffix in [".py", ".ts"]]
return len(relevant_files) >= 1
except subprocess.CalledProcessError:
return False
def gen_version() -> Version:
"""Generate version based on current date"""
now = datetime.datetime.now()
return Version(f"{now.year}.{now.month}.{now.day}")
def find_changed_packages(directory: Path, git_hash: GitHash) -> Iterator[Package]:
for path in directory.glob("*/package.json"):
if has_changes(path.parent, git_hash):
yield NpmPackage(path.parent)
for path in directory.glob("*/pyproject.toml"):
if has_changes(path.parent, git_hash):
yield PyPiPackage(path.parent)
@click.group()
def cli():
pass
@cli.command("update-packages")
@click.option(
"--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd()
)
@click.argument("git_hash", type=GIT_HASH)
def update_packages(directory: Path, git_hash: GitHash) -> int:
# Detect package type
path = directory.resolve(strict=True)
version = gen_version()
for package in find_changed_packages(path, git_hash):
name = package.package_name()
package.update_version(version)
click.echo(f"{name}@{version}")
return 0
@cli.command("generate-notes")
@click.option(
"--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd()
)
@click.argument("git_hash", type=GIT_HASH)
def generate_notes(directory: Path, git_hash: GitHash) -> int:
# Detect package type
path = directory.resolve(strict=True)
version = gen_version()
click.echo(f"# Release : v{version}")
click.echo("")
click.echo("## Updated packages")
for package in find_changed_packages(path, git_hash):
name = package.package_name()
click.echo(f"- {name}@{version}")
return 0
@cli.command("generate-version")
def generate_version() -> int:
# Detect package type
click.echo(gen_version())
return 0
@cli.command("generate-matrix")
@click.option(
"--directory", type=click.Path(exists=True, path_type=Path), default=Path.cwd()
)
@click.option("--npm", is_flag=True, default=False)
@click.option("--pypi", is_flag=True, default=False)
@click.argument("git_hash", type=GIT_HASH)
def generate_matrix(directory: Path, git_hash: GitHash, pypi: bool, npm: bool) -> int:
# Detect package type
path = directory.resolve(strict=True)
version = gen_version()
changes = []
for package in find_changed_packages(path, git_hash):
pkg = package.path.relative_to(path)
if npm and isinstance(package, NpmPackage):
changes.append(str(pkg))
if pypi and isinstance(package, PyPiPackage):
changes.append(str(pkg))
click.echo(json.dumps(changes))
return 0
if __name__ == "__main__":
sys.exit(cli())

View File

@@ -0,0 +1,4 @@
packages
dist
README.md
node_modules

View File

@@ -0,0 +1,52 @@
# MCP "Everything" Server - Development Guidelines
## Build, Test & Run Commands
- Build: `npm run build` - Compiles TypeScript to JavaScript
- Watch mode: `npm run watch` - Watches for changes and rebuilds automatically
- Run STDIO server: `npm run start:stdio` - Starts the MCP server using stdio transport
- Run SSE server: `npm run start:sse` - Starts the MCP server with SSE transport
- Run StreamableHttp server: `npm run start:stremableHttp` - Starts the MCP server with StreamableHttp transport
- Prepare release: `npm run prepare` - Builds the project for publishing
## Code Style Guidelines
- Use ES modules with `.js` extension in import paths
- Strictly type all functions and variables with TypeScript
- Follow zod schema patterns for tool input validation
- Prefer async/await over callbacks and Promise chains
- Place all imports at top of file, grouped by external then internal
- Use descriptive variable names that clearly indicate purpose
- Implement proper cleanup for timers and resources in server shutdown
- Handle errors with try/catch blocks and provide clear error messages
- Use consistent indentation (2 spaces) and trailing commas in multi-line objects
- Match existing code style, import order, and module layout in the respective folder.
- Use camelCase for variables/functions,
- Use PascalCase for types/classes,
- Use UPPER_CASE for constants
- Use kebab-case for file names and registered tools, prompts, and resources.
- Use verbs for tool names, e.g., `get-annotated-message` instead of `annotated-message`
## Extending the Server
The Everything Server is designed to be extended at well-defined points.
See [Extension Points](docs/extension.md) and [Project Structure](docs/structure.md).
The server factory is `src/everything/server/index.ts` and registers all features during startup as well as handling post-connection setup.
### High-level
- Tools live under `src/everything/tools/` and are registered via `registerTools(server)`.
- Resources live under `src/everything/resources/` and are registered via `registerResources(server)`.
- Prompts live under `src/everything/prompts/` and are registered via `registerPrompts(server)`.
- Subscriptions and simulated update routines are under `src/everything/resources/subscriptions.ts`.
- Logging helpers are under `src/everything/server/logging.ts`.
- Transport managers are under `src/everything/transports/`.
### When adding a new feature
- Follow the existing file/module pattern in its folder (naming, exports, and registration function).
- Export a `registerX(server)` function that registers new items with the MCP SDK in the same style as existing ones.
- Wire your new module into the central index (e.g., update `tools/index.ts`, `resources/index.ts`, or `prompts/index.ts`).
- Ensure schemas (for tools) are accurate JSON Schema and include helpful descriptions and examples.
`server/index.ts` and usages in `logging.ts` and `subscriptions.ts`.
- Keep the docs in `src/everything/docs/` up to date if you add or modify noteworthy features.

View File

@@ -0,0 +1,22 @@
FROM node:22.12-alpine AS builder
COPY src/everything /app
COPY tsconfig.json /tsconfig.json
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install
FROM node:22-alpine AS release
WORKDIR /app
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/package-lock.json /app/package-lock.json
ENV NODE_ENV=production
RUN npm ci --ignore-scripts --omit-dev
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,106 @@
# Everything MCP Server
**[Architecture](docs/architecture.md)
| [Project Structure](docs/structure.md)
| [Startup Process](docs/startup.md)
| [Server Features](docs/features.md)
| [Extension Points](docs/extension.md)
| [How It Works](docs/how-it-works.md)**
This MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities.
## Tools, Resources, Prompts, and Other Features
A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document.
## Usage with Claude Desktop (uses [stdio Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio))
Add to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"everything": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-everything"
]
}
}
}
```
## Usage with VS Code
For quick installation, use of of the one-click install buttons below...
[![Install with NPX in VS Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=everything&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-everything%22%5D%7D) [![Install with NPX in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=everything&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-everything%22%5D%7D&quality=insiders)
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Docker-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=everything&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22mcp%2Feverything%22%5D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Docker-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=everything&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22mcp%2Feverything%22%5D%7D&quality=insiders)
For manual installation, you can configure the MCP server using one of these methods:
**Method 1: User Configuration (Recommended)**
Add the configuration to your user-level MCP configuration file. Open the Command Palette (`Ctrl + Shift + P`) and run `MCP: Open User Configuration`. This will open your user `mcp.json` file where you can add the server configuration.
**Method 2: Workspace Configuration**
Alternatively, you can add the configuration to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers).
#### NPX
```json
{
"servers": {
"everything": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}
```
## Running from source with [HTTP+SSE Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) (deprecated as of [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports))
```shell
cd src/everything
npm install
npm run start:sse
```
## Run from source with [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http)
```shell
cd src/everything
npm install
npm run start:streamableHttp
```
## Running as an installed package
### Install
```shell
npm install -g @modelcontextprotocol/server-everything@latest
````
### Run the default (stdio) server
```shell
npx @modelcontextprotocol/server-everything
```
### Or specify stdio explicitly
```shell
npx @modelcontextprotocol/server-everything stdio
```
### Run the SSE server
```shell
npx @modelcontextprotocol/server-everything sse
```
### Run the streamable HTTP server
```shell
npx @modelcontextprotocol/server-everything streamableHttp
```

View File

@@ -0,0 +1,179 @@
import { describe, it, expect, vi } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerSimplePrompt } from '../prompts/simple.js';
import { registerArgumentsPrompt } from '../prompts/args.js';
import { registerPromptWithCompletions } from '../prompts/completions.js';
import { registerEmbeddedResourcePrompt } from '../prompts/resource.js';
// Helper to capture registered prompt handlers
function createMockServer() {
const handlers: Map<string, Function> = new Map();
const configs: Map<string, any> = new Map();
const mockServer = {
registerPrompt: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
configs.set(name, config);
}),
} as unknown as McpServer;
return { mockServer, handlers, configs };
}
describe('Prompts', () => {
describe('simple-prompt', () => {
it('should return fixed message with no arguments', () => {
const { mockServer, handlers } = createMockServer();
registerSimplePrompt(mockServer);
const handler = handlers.get('simple-prompt')!;
const result = handler();
expect(result).toEqual({
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'This is a simple prompt without arguments.',
},
},
],
});
});
});
describe('args-prompt', () => {
it('should include city in message', () => {
const { mockServer, handlers } = createMockServer();
registerArgumentsPrompt(mockServer);
const handler = handlers.get('args-prompt')!;
const result = handler({ city: 'San Francisco' });
expect(result.messages[0].content.text).toBe("What's weather in San Francisco?");
});
it('should include city and state in message', () => {
const { mockServer, handlers } = createMockServer();
registerArgumentsPrompt(mockServer);
const handler = handlers.get('args-prompt')!;
const result = handler({ city: 'San Francisco', state: 'California' });
expect(result.messages[0].content.text).toBe(
"What's weather in San Francisco, California?"
);
});
it('should handle city only (optional state omitted)', () => {
const { mockServer, handlers } = createMockServer();
registerArgumentsPrompt(mockServer);
const handler = handlers.get('args-prompt')!;
const result = handler({ city: 'New York' });
expect(result.messages[0].content.text).toBe("What's weather in New York?");
expect(result.messages[0].content.text).not.toContain(',');
expect(result.messages[0].role).toBe('user');
expect(result.messages[0].content.type).toBe('text');
});
});
describe('completable-prompt', () => {
it('should generate promotion message with department and name', () => {
const { mockServer, handlers } = createMockServer();
registerPromptWithCompletions(mockServer);
const handler = handlers.get('completable-prompt')!;
const result = handler({ department: 'Engineering', name: 'Alice' });
expect(result.messages[0].content.text).toBe(
'Please promote Alice to the head of the Engineering team.'
);
});
it('should work with different departments', () => {
const { mockServer, handlers } = createMockServer();
registerPromptWithCompletions(mockServer);
const handler = handlers.get('completable-prompt')!;
const salesResult = handler({ department: 'Sales', name: 'David' });
expect(salesResult.messages[0].content.text).toContain('Sales');
expect(salesResult.messages[0].content.text).toContain('David');
expect(salesResult.messages[0].role).toBe('user');
const marketingResult = handler({ department: 'Marketing', name: 'Grace' });
expect(marketingResult.messages[0].content.text).toContain('Marketing');
expect(marketingResult.messages[0].content.text).toContain('Grace');
});
});
describe('resource-prompt', () => {
it('should return text resource reference', () => {
const { mockServer, handlers } = createMockServer();
registerEmbeddedResourcePrompt(mockServer);
const handler = handlers.get('resource-prompt')!;
const result = handler({ resourceType: 'Text', resourceId: '1' });
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content.text).toContain('Text');
expect(result.messages[0].content.text).toContain('1');
expect(result.messages[1].content.type).toBe('resource');
expect(result.messages[1].content.resource.uri).toContain('text/1');
});
it('should return blob resource reference', () => {
const { mockServer, handlers } = createMockServer();
registerEmbeddedResourcePrompt(mockServer);
const handler = handlers.get('resource-prompt')!;
const result = handler({ resourceType: 'Blob', resourceId: '5' });
expect(result.messages[0].content.text).toContain('Blob');
expect(result.messages[1].content.resource.uri).toContain('blob/5');
});
it('should reject invalid resource type', () => {
const { mockServer, handlers } = createMockServer();
registerEmbeddedResourcePrompt(mockServer);
const handler = handlers.get('resource-prompt')!;
expect(() => handler({ resourceType: 'Invalid', resourceId: '1' })).toThrow(
'Invalid resourceType'
);
});
it('should reject invalid resource ID', () => {
const { mockServer, handlers } = createMockServer();
registerEmbeddedResourcePrompt(mockServer);
const handler = handlers.get('resource-prompt')!;
expect(() => handler({ resourceType: 'Text', resourceId: '-1' })).toThrow(
'Invalid resourceId'
);
expect(() => handler({ resourceType: 'Text', resourceId: '0' })).toThrow(
'Invalid resourceId'
);
expect(() => handler({ resourceType: 'Text', resourceId: 'abc' })).toThrow(
'Invalid resourceId'
);
});
it('should include both intro text and resource messages', () => {
const { mockServer, handlers } = createMockServer();
registerEmbeddedResourcePrompt(mockServer);
const handler = handlers.get('resource-prompt')!;
const result = handler({ resourceType: 'Text', resourceId: '3' });
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe('user');
expect(result.messages[0].content.type).toBe('text');
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content.type).toBe('resource');
});
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
// Create mock server
function createMockServer() {
return {
registerTool: vi.fn(),
registerPrompt: vi.fn(),
registerResource: vi.fn(),
server: {
getClientCapabilities: vi.fn(() => ({})),
setRequestHandler: vi.fn(),
},
sendLoggingMessage: vi.fn(),
sendResourceUpdated: vi.fn(),
} as unknown as McpServer;
}
describe('Registration Index Files', () => {
describe('tools/index.ts', () => {
it('should register all standard tools', async () => {
const { registerTools } = await import('../tools/index.js');
const mockServer = createMockServer();
registerTools(mockServer);
// Should register 12 standard tools (non-conditional)
expect(mockServer.registerTool).toHaveBeenCalledTimes(12);
// Verify specific tools are registered
const registeredTools = (mockServer.registerTool as any).mock.calls.map(
(call: any[]) => call[0]
);
expect(registeredTools).toContain('echo');
expect(registeredTools).toContain('get-sum');
expect(registeredTools).toContain('get-env');
expect(registeredTools).toContain('get-tiny-image');
expect(registeredTools).toContain('get-structured-content');
expect(registeredTools).toContain('get-annotated-message');
expect(registeredTools).toContain('trigger-long-running-operation');
expect(registeredTools).toContain('get-resource-links');
expect(registeredTools).toContain('get-resource-reference');
expect(registeredTools).toContain('gzip-file-as-resource');
expect(registeredTools).toContain('toggle-simulated-logging');
expect(registeredTools).toContain('toggle-subscriber-updates');
});
it('should register conditional tools based on capabilities', async () => {
const { registerConditionalTools } = await import('../tools/index.js');
// Server with all capabilities including experimental tasks API
const mockServerWithCapabilities = {
registerTool: vi.fn(),
server: {
getClientCapabilities: vi.fn(() => ({
roots: {},
elicitation: {},
sampling: {},
})),
},
experimental: {
tasks: {
registerToolTask: vi.fn(),
},
},
} as unknown as McpServer;
registerConditionalTools(mockServerWithCapabilities);
// Should register 3 conditional tools + 3 task-based tools when all capabilities present
expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(3);
const registeredTools = (
mockServerWithCapabilities.registerTool as any
).mock.calls.map((call: any[]) => call[0]);
expect(registeredTools).toContain('get-roots-list');
expect(registeredTools).toContain('trigger-elicitation-request');
expect(registeredTools).toContain('trigger-sampling-request');
// Task-based tools are registered via experimental.tasks.registerToolTask
expect(mockServerWithCapabilities.experimental.tasks.registerToolTask).toHaveBeenCalled();
});
it('should not register conditional tools when capabilities missing', async () => {
const { registerConditionalTools } = await import('../tools/index.js');
const mockServerNoCapabilities = {
registerTool: vi.fn(),
server: {
getClientCapabilities: vi.fn(() => ({})),
},
experimental: {
tasks: {
registerToolTask: vi.fn(),
},
},
} as unknown as McpServer;
registerConditionalTools(mockServerNoCapabilities);
// Should not register any capability-gated tools when capabilities are missing
expect(mockServerNoCapabilities.registerTool).not.toHaveBeenCalled();
});
});
describe('prompts/index.ts', () => {
it('should register all prompts', async () => {
const { registerPrompts } = await import('../prompts/index.js');
const mockServer = createMockServer();
registerPrompts(mockServer);
// Should register 4 prompts
expect(mockServer.registerPrompt).toHaveBeenCalledTimes(4);
const registeredPrompts = (mockServer.registerPrompt as any).mock.calls.map(
(call: any[]) => call[0]
);
expect(registeredPrompts).toContain('simple-prompt');
expect(registeredPrompts).toContain('args-prompt');
expect(registeredPrompts).toContain('completable-prompt');
expect(registeredPrompts).toContain('resource-prompt');
});
});
describe('resources/index.ts', () => {
it('should register resource templates', async () => {
const { registerResources } = await import('../resources/index.js');
const mockServer = createMockServer();
registerResources(mockServer);
// Should register at least the 2 resource templates (text and blob) plus file resources
expect(mockServer.registerResource).toHaveBeenCalled();
const registeredResources = (mockServer.registerResource as any).mock.calls.map(
(call: any[]) => call[0]
);
expect(registeredResources).toContain('Dynamic Text Resource');
expect(registeredResources).toContain('Dynamic Blob Resource');
});
it('should read instructions from file', async () => {
const { readInstructions } = await import('../resources/index.js');
const instructions = readInstructions();
// Should return a string (either content or error message)
expect(typeof instructions).toBe('string');
expect(instructions.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,327 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
textResource,
blobResource,
textResourceUri,
blobResourceUri,
RESOURCE_TYPE_TEXT,
RESOURCE_TYPE_BLOB,
RESOURCE_TYPES,
resourceTypeCompleter,
resourceIdForPromptCompleter,
resourceIdForResourceTemplateCompleter,
registerResourceTemplates,
} from '../resources/templates.js';
import {
getSessionResourceURI,
registerSessionResource,
} from '../resources/session.js';
import { registerFileResources } from '../resources/files.js';
import {
setSubscriptionHandlers,
beginSimulatedResourceUpdates,
stopSimulatedResourceUpdates,
} from '../resources/subscriptions.js';
describe('Resource Templates', () => {
describe('Constants', () => {
it('should include both types in RESOURCE_TYPES array', () => {
expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_TEXT);
expect(RESOURCE_TYPES).toContain(RESOURCE_TYPE_BLOB);
expect(RESOURCE_TYPES).toHaveLength(2);
});
});
describe('textResourceUri', () => {
it('should create URL for text resource', () => {
const uri = textResourceUri(1);
expect(uri.toString()).toBe('demo://resource/dynamic/text/1');
});
it('should handle different resource IDs', () => {
expect(textResourceUri(5).toString()).toBe('demo://resource/dynamic/text/5');
expect(textResourceUri(100).toString()).toBe('demo://resource/dynamic/text/100');
});
});
describe('blobResourceUri', () => {
it('should create URL for blob resource', () => {
const uri = blobResourceUri(1);
expect(uri.toString()).toBe('demo://resource/dynamic/blob/1');
});
it('should handle different resource IDs', () => {
expect(blobResourceUri(5).toString()).toBe('demo://resource/dynamic/blob/5');
expect(blobResourceUri(100).toString()).toBe('demo://resource/dynamic/blob/100');
});
});
describe('textResource', () => {
it('should create text resource with correct structure', () => {
const uri = textResourceUri(1);
const resource = textResource(uri, 1);
expect(resource.uri).toBe(uri.toString());
expect(resource.mimeType).toBe('text/plain');
expect(resource.text).toContain('Resource 1');
expect(resource.text).toContain('plaintext');
});
it('should include timestamp in content', () => {
const uri = textResourceUri(2);
const resource = textResource(uri, 2);
// Timestamp format varies, just check it contains time-related content
expect(resource.text).toMatch(/\d/);
});
});
describe('blobResource', () => {
it('should create blob resource with correct structure', () => {
const uri = blobResourceUri(1);
const resource = blobResource(uri, 1);
expect(resource.uri).toBe(uri.toString());
expect(resource.mimeType).toBe('text/plain');
expect(resource.blob).toBeDefined();
});
it('should create valid base64 encoded content', () => {
const uri = blobResourceUri(3);
const resource = blobResource(uri, 3);
// Decode and verify content
const decoded = Buffer.from(resource.blob, 'base64').toString();
expect(decoded).toContain('Resource 3');
expect(decoded).toContain('base64 blob');
});
});
describe('resourceTypeCompleter', () => {
it('should be defined as a completable schema', () => {
// The completer is a zod schema wrapped with completable
expect(resourceTypeCompleter).toBeDefined();
// It should have the zod parse method
expect(typeof (resourceTypeCompleter as any).parse).toBe('function');
});
it('should validate string resource types', () => {
// Test that valid strings pass validation
expect(() => (resourceTypeCompleter as any).parse('Text')).not.toThrow();
expect(() => (resourceTypeCompleter as any).parse('Blob')).not.toThrow();
});
});
describe('resourceIdForPromptCompleter', () => {
it('should be defined as a completable schema', () => {
expect(resourceIdForPromptCompleter).toBeDefined();
expect(typeof (resourceIdForPromptCompleter as any).parse).toBe('function');
});
it('should validate string IDs', () => {
// Test that valid strings pass validation
expect(() => (resourceIdForPromptCompleter as any).parse('1')).not.toThrow();
expect(() => (resourceIdForPromptCompleter as any).parse('100')).not.toThrow();
});
});
describe('resourceIdForResourceTemplateCompleter', () => {
it('should validate positive integer IDs', () => {
expect(resourceIdForResourceTemplateCompleter('1')).toEqual(['1']);
expect(resourceIdForResourceTemplateCompleter('50')).toEqual(['50']);
});
it('should reject invalid IDs', () => {
expect(resourceIdForResourceTemplateCompleter('0')).toEqual([]);
expect(resourceIdForResourceTemplateCompleter('-5')).toEqual([]);
expect(resourceIdForResourceTemplateCompleter('not-a-number')).toEqual([]);
});
});
describe('registerResourceTemplates', () => {
it('should register text and blob resource templates', () => {
const registeredResources: any[] = [];
const mockServer = {
registerResource: vi.fn((...args) => {
registeredResources.push(args);
}),
} as unknown as McpServer;
registerResourceTemplates(mockServer);
expect(mockServer.registerResource).toHaveBeenCalledTimes(2);
// Check text resource registration
const textRegistration = registeredResources.find((r) =>
r[0].includes('Text')
);
expect(textRegistration).toBeDefined();
expect(textRegistration[1]).toBeInstanceOf(ResourceTemplate);
// Check blob resource registration
const blobRegistration = registeredResources.find((r) =>
r[0].includes('Blob')
);
expect(blobRegistration).toBeDefined();
});
});
});
describe('Session Resources', () => {
describe('getSessionResourceURI', () => {
it('should generate correct URI for resource name', () => {
expect(getSessionResourceURI('test')).toBe('demo://resource/session/test');
});
it('should handle various resource names', () => {
expect(getSessionResourceURI('my-file')).toBe('demo://resource/session/my-file');
expect(getSessionResourceURI('document_123')).toBe(
'demo://resource/session/document_123'
);
});
});
describe('registerSessionResource', () => {
it('should register text resource and return resource link', () => {
const registrations: any[] = [];
const mockServer = {
registerResource: vi.fn((...args) => {
registrations.push(args);
}),
} as unknown as McpServer;
const resource = {
uri: 'demo://resource/session/test-file',
name: 'test-file',
mimeType: 'text/plain',
description: 'A test file',
};
const result = registerSessionResource(
mockServer,
resource,
'text',
'Hello, World!'
);
expect(result.type).toBe('resource_link');
expect(result.uri).toBe(resource.uri);
expect(result.name).toBe(resource.name);
expect(mockServer.registerResource).toHaveBeenCalledWith(
'test-file',
'demo://resource/session/test-file',
expect.objectContaining({
mimeType: 'text/plain',
description: 'A test file',
}),
expect.any(Function)
);
});
it('should register blob resource correctly', () => {
const mockServer = {
registerResource: vi.fn(),
} as unknown as McpServer;
const resource = {
uri: 'demo://resource/session/binary-file',
name: 'binary-file',
mimeType: 'application/octet-stream',
};
const blobContent = Buffer.from('binary data').toString('base64');
const result = registerSessionResource(mockServer, resource, 'blob', blobContent);
expect(result.type).toBe('resource_link');
expect(mockServer.registerResource).toHaveBeenCalled();
});
it('should return resource handler that provides correct content', async () => {
let capturedHandler: Function | null = null;
const mockServer = {
registerResource: vi.fn((_name, _uri, _config, handler) => {
capturedHandler = handler;
}),
} as unknown as McpServer;
const resource = {
uri: 'demo://resource/session/content-test',
name: 'content-test',
mimeType: 'text/plain',
};
registerSessionResource(mockServer, resource, 'text', 'Test content here');
expect(capturedHandler).not.toBeNull();
const handlerResult = await capturedHandler!(new URL(resource.uri));
expect(handlerResult.contents).toHaveLength(1);
expect(handlerResult.contents[0].text).toBe('Test content here');
expect(handlerResult.contents[0].mimeType).toBe('text/plain');
});
});
});
describe('File Resources', () => {
describe('registerFileResources', () => {
it('should register file resources when docs directory exists', () => {
const mockServer = {
registerResource: vi.fn(),
} as unknown as McpServer;
registerFileResources(mockServer);
// The docs folder exists in the everything server and contains files
// so registerResource should have been called
expect(mockServer.registerResource).toHaveBeenCalled();
});
});
});
describe('Subscriptions', () => {
describe('setSubscriptionHandlers', () => {
it('should set request handlers on server', () => {
const mockServer = {
server: {
setRequestHandler: vi.fn(),
},
sendLoggingMessage: vi.fn(),
} as unknown as McpServer;
setSubscriptionHandlers(mockServer);
// Should set both subscribe and unsubscribe handlers
expect(mockServer.server.setRequestHandler).toHaveBeenCalledTimes(2);
});
});
describe('simulated resource updates lifecycle', () => {
afterEach(() => {
// Clean up any intervals
stopSimulatedResourceUpdates('lifecycle-test-session');
});
it('should start and stop updates without errors', () => {
const mockServer = {
server: {
notification: vi.fn(),
},
} as unknown as McpServer;
// Start updates - should work for both defined and undefined sessionId
beginSimulatedResourceUpdates(mockServer, 'lifecycle-test-session');
beginSimulatedResourceUpdates(mockServer, undefined);
// Stop updates - should handle all cases gracefully
stopSimulatedResourceUpdates('lifecycle-test-session');
stopSimulatedResourceUpdates('non-existent-session');
stopSimulatedResourceUpdates(undefined);
// If we got here without throwing, the lifecycle works correctly
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest';
import { createServer } from '../server/index.js';
describe('Server Factory', () => {
describe('createServer', () => {
it('should return a ServerFactoryResponse object', () => {
const result = createServer();
expect(result).toHaveProperty('server');
expect(result).toHaveProperty('cleanup');
});
it('should return a cleanup function', () => {
const { cleanup } = createServer();
expect(typeof cleanup).toBe('function');
});
it('should create an McpServer instance', () => {
const { server } = createServer();
expect(server).toBeDefined();
expect(server.server).toBeDefined();
});
it('should have an oninitialized handler set', () => {
const { server } = createServer();
expect(server.server.oninitialized).toBeDefined();
});
it('should allow multiple servers to be created', () => {
const result1 = createServer();
const result2 = createServer();
expect(result1.server).toBeDefined();
expect(result2.server).toBeDefined();
expect(result1.server).not.toBe(result2.server);
});
});
});

View File

@@ -0,0 +1,820 @@
import { describe, it, expect, vi } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerEchoTool, EchoSchema } from '../tools/echo.js';
import { registerGetSumTool } from '../tools/get-sum.js';
import { registerGetEnvTool } from '../tools/get-env.js';
import { registerGetTinyImageTool, MCP_TINY_IMAGE } from '../tools/get-tiny-image.js';
import { registerGetStructuredContentTool } from '../tools/get-structured-content.js';
import { registerGetAnnotatedMessageTool } from '../tools/get-annotated-message.js';
import { registerTriggerLongRunningOperationTool } from '../tools/trigger-long-running-operation.js';
import { registerGetResourceLinksTool } from '../tools/get-resource-links.js';
import { registerGetResourceReferenceTool } from '../tools/get-resource-reference.js';
import { registerToggleSimulatedLoggingTool } from '../tools/toggle-simulated-logging.js';
import { registerToggleSubscriberUpdatesTool } from '../tools/toggle-subscriber-updates.js';
import { registerTriggerSamplingRequestTool } from '../tools/trigger-sampling-request.js';
import { registerTriggerElicitationRequestTool } from '../tools/trigger-elicitation-request.js';
import { registerGetRootsListTool } from '../tools/get-roots-list.js';
import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';
// Helper to capture registered tool handlers
function createMockServer() {
const handlers: Map<string, Function> = new Map();
const configs: Map<string, any> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
configs.set(name, config);
}),
server: {
getClientCapabilities: vi.fn(() => ({})),
notification: vi.fn(),
},
sendLoggingMessage: vi.fn(),
sendResourceUpdated: vi.fn(),
} as unknown as McpServer;
return { mockServer, handlers, configs };
}
describe('Tools', () => {
describe('echo', () => {
it('should echo back the message', async () => {
const { mockServer, handlers } = createMockServer();
registerEchoTool(mockServer);
const handler = handlers.get('echo')!;
const result = await handler({ message: 'Hello, World!' });
expect(result).toEqual({
content: [{ type: 'text', text: 'Echo: Hello, World!' }],
});
});
it('should handle empty message', async () => {
const { mockServer, handlers } = createMockServer();
registerEchoTool(mockServer);
const handler = handlers.get('echo')!;
const result = await handler({ message: '' });
expect(result).toEqual({
content: [{ type: 'text', text: 'Echo: ' }],
});
});
it('should reject invalid input', async () => {
const { mockServer, handlers } = createMockServer();
registerEchoTool(mockServer);
const handler = handlers.get('echo')!;
await expect(handler({})).rejects.toThrow();
await expect(handler({ message: 123 })).rejects.toThrow();
});
});
describe('EchoSchema', () => {
it('should validate correct input', () => {
const result = EchoSchema.parse({ message: 'test' });
expect(result).toEqual({ message: 'test' });
});
it('should reject missing message', () => {
expect(() => EchoSchema.parse({})).toThrow();
});
it('should reject non-string message', () => {
expect(() => EchoSchema.parse({ message: 123 })).toThrow();
});
});
describe('get-sum', () => {
it('should calculate sum of two positive numbers', async () => {
const { mockServer, handlers } = createMockServer();
registerGetSumTool(mockServer);
const handler = handlers.get('get-sum')!;
const result = await handler({ a: 5, b: 3 });
expect(result).toEqual({
content: [{ type: 'text', text: 'The sum of 5 and 3 is 8.' }],
});
});
it('should calculate sum with negative numbers', async () => {
const { mockServer, handlers } = createMockServer();
registerGetSumTool(mockServer);
const handler = handlers.get('get-sum')!;
const result = await handler({ a: -5, b: 3 });
expect(result).toEqual({
content: [{ type: 'text', text: 'The sum of -5 and 3 is -2.' }],
});
});
it('should calculate sum with zero', async () => {
const { mockServer, handlers } = createMockServer();
registerGetSumTool(mockServer);
const handler = handlers.get('get-sum')!;
const result = await handler({ a: 0, b: 0 });
expect(result).toEqual({
content: [{ type: 'text', text: 'The sum of 0 and 0 is 0.' }],
});
});
it('should handle floating point numbers', async () => {
const { mockServer, handlers } = createMockServer();
registerGetSumTool(mockServer);
const handler = handlers.get('get-sum')!;
const result = await handler({ a: 1.5, b: 2.5 });
expect(result).toEqual({
content: [{ type: 'text', text: 'The sum of 1.5 and 2.5 is 4.' }],
});
});
it('should reject invalid input', async () => {
const { mockServer, handlers } = createMockServer();
registerGetSumTool(mockServer);
const handler = handlers.get('get-sum')!;
await expect(handler({})).rejects.toThrow();
await expect(handler({ a: 'not a number', b: 5 })).rejects.toThrow();
await expect(handler({ a: 5 })).rejects.toThrow();
});
});
describe('get-env', () => {
it('should return all environment variables as JSON', async () => {
const { mockServer, handlers } = createMockServer();
registerGetEnvTool(mockServer);
const handler = handlers.get('get-env')!;
process.env.TEST_VAR_EVERYTHING = 'test_value';
const result = await handler({});
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe('text');
const envJson = JSON.parse(result.content[0].text);
expect(envJson.TEST_VAR_EVERYTHING).toBe('test_value');
delete process.env.TEST_VAR_EVERYTHING;
});
it('should return valid JSON', async () => {
const { mockServer, handlers } = createMockServer();
registerGetEnvTool(mockServer);
const handler = handlers.get('get-env')!;
const result = await handler({});
expect(() => JSON.parse(result.content[0].text)).not.toThrow();
});
});
describe('get-tiny-image', () => {
it('should return image content with text descriptions', async () => {
const { mockServer, handlers } = createMockServer();
registerGetTinyImageTool(mockServer);
const handler = handlers.get('get-tiny-image')!;
const result = await handler({});
expect(result.content).toHaveLength(3);
expect(result.content[0]).toEqual({
type: 'text',
text: "Here's the image you requested:",
});
expect(result.content[1]).toEqual({
type: 'image',
data: MCP_TINY_IMAGE,
mimeType: 'image/png',
});
expect(result.content[2]).toEqual({
type: 'text',
text: 'The image above is the MCP logo.',
});
});
it('should return valid base64 image data', async () => {
const { mockServer, handlers } = createMockServer();
registerGetTinyImageTool(mockServer);
const handler = handlers.get('get-tiny-image')!;
const result = await handler({});
const imageContent = result.content[1];
expect(imageContent.type).toBe('image');
expect(imageContent.mimeType).toBe('image/png');
// Verify it's valid base64
expect(() => Buffer.from(imageContent.data, 'base64')).not.toThrow();
});
});
describe('get-structured-content', () => {
it('should return weather for New York', async () => {
const { mockServer, handlers } = createMockServer();
registerGetStructuredContentTool(mockServer);
const handler = handlers.get('get-structured-content')!;
const result = await handler({ location: 'New York' });
expect(result.structuredContent).toEqual({
temperature: 33,
conditions: 'Cloudy',
humidity: 82,
});
expect(result.content[0].type).toBe('text');
expect(JSON.parse(result.content[0].text)).toEqual(result.structuredContent);
});
it('should return weather for Chicago', async () => {
const { mockServer, handlers } = createMockServer();
registerGetStructuredContentTool(mockServer);
const handler = handlers.get('get-structured-content')!;
const result = await handler({ location: 'Chicago' });
expect(result.structuredContent).toEqual({
temperature: 36,
conditions: 'Light rain / drizzle',
humidity: 82,
});
});
it('should return weather for Los Angeles', async () => {
const { mockServer, handlers } = createMockServer();
registerGetStructuredContentTool(mockServer);
const handler = handlers.get('get-structured-content')!;
const result = await handler({ location: 'Los Angeles' });
expect(result.structuredContent).toEqual({
temperature: 73,
conditions: 'Sunny / Clear',
humidity: 48,
});
});
});
describe('get-annotated-message', () => {
it('should return error message with high priority', async () => {
const { mockServer, handlers } = createMockServer();
registerGetAnnotatedMessageTool(mockServer);
const handler = handlers.get('get-annotated-message')!;
const result = await handler({ messageType: 'error', includeImage: false });
expect(result.content).toHaveLength(1);
expect(result.content[0].text).toBe('Error: Operation failed');
expect(result.content[0].annotations).toEqual({
priority: 1.0,
audience: ['user', 'assistant'],
});
});
it('should return success message with medium priority', async () => {
const { mockServer, handlers } = createMockServer();
registerGetAnnotatedMessageTool(mockServer);
const handler = handlers.get('get-annotated-message')!;
const result = await handler({ messageType: 'success', includeImage: false });
expect(result.content[0].text).toBe('Operation completed successfully');
expect(result.content[0].annotations.priority).toBe(0.7);
expect(result.content[0].annotations.audience).toEqual(['user']);
});
it('should return debug message with low priority', async () => {
const { mockServer, handlers } = createMockServer();
registerGetAnnotatedMessageTool(mockServer);
const handler = handlers.get('get-annotated-message')!;
const result = await handler({ messageType: 'debug', includeImage: false });
expect(result.content[0].text).toContain('Debug:');
expect(result.content[0].annotations.priority).toBe(0.3);
expect(result.content[0].annotations.audience).toEqual(['assistant']);
});
it('should include annotated image when requested', async () => {
const { mockServer, handlers } = createMockServer();
registerGetAnnotatedMessageTool(mockServer);
const handler = handlers.get('get-annotated-message')!;
const result = await handler({ messageType: 'success', includeImage: true });
expect(result.content).toHaveLength(2);
expect(result.content[1].type).toBe('image');
expect(result.content[1].annotations).toEqual({
priority: 0.5,
audience: ['user'],
});
});
});
describe('trigger-long-running-operation', () => {
it('should complete operation and return result', async () => {
const { mockServer, handlers } = createMockServer();
registerTriggerLongRunningOperationTool(mockServer);
const handler = handlers.get('trigger-long-running-operation')!;
// Use very short duration for test
const result = await handler(
{ duration: 0.1, steps: 2 },
{ _meta: {}, requestId: 'test-123' }
);
expect(result.content[0].text).toContain('Long running operation completed');
expect(result.content[0].text).toContain('Duration: 0.1 seconds');
expect(result.content[0].text).toContain('Steps: 2');
}, 10000);
it('should send progress notifications when progressToken provided', async () => {
const { mockServer, handlers } = createMockServer();
registerTriggerLongRunningOperationTool(mockServer);
const handler = handlers.get('trigger-long-running-operation')!;
await handler(
{ duration: 0.1, steps: 2 },
{ _meta: { progressToken: 'token-123' }, requestId: 'test-456', sessionId: 'session-1' }
);
expect(mockServer.server.notification).toHaveBeenCalledTimes(2);
expect(mockServer.server.notification).toHaveBeenCalledWith(
expect.objectContaining({
method: 'notifications/progress',
params: expect.objectContaining({
progressToken: 'token-123',
}),
}),
expect.any(Object)
);
}, 10000);
});
describe('get-resource-links', () => {
it('should return specified number of resource links', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceLinksTool(mockServer);
const handler = handlers.get('get-resource-links')!;
const result = await handler({ count: 3 });
// 1 intro text + 3 resource links
expect(result.content).toHaveLength(4);
expect(result.content[0].type).toBe('text');
expect(result.content[0].text).toContain('3 resource links');
// Check resource links
for (let i = 1; i < 4; i++) {
expect(result.content[i].type).toBe('resource_link');
expect(result.content[i].uri).toBeDefined();
expect(result.content[i].name).toBeDefined();
}
});
it('should alternate between text and blob resources', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceLinksTool(mockServer);
const handler = handlers.get('get-resource-links')!;
const result = await handler({ count: 4 });
// Odd IDs (1, 3) are blob, even IDs (2, 4) are text
expect(result.content[1].name).toContain('Blob');
expect(result.content[2].name).toContain('Text');
expect(result.content[3].name).toContain('Blob');
expect(result.content[4].name).toContain('Text');
});
it('should use default count of 3', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceLinksTool(mockServer);
const handler = handlers.get('get-resource-links')!;
const result = await handler({});
// 1 intro text + 3 resource links (default)
expect(result.content).toHaveLength(4);
});
});
describe('get-resource-reference', () => {
it('should return text resource reference', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceReferenceTool(mockServer);
const handler = handlers.get('get-resource-reference')!;
const result = await handler({ resourceType: 'Text', resourceId: 1 });
expect(result.content).toHaveLength(3);
expect(result.content[0].text).toContain('Resource 1');
expect(result.content[1].type).toBe('resource');
expect(result.content[1].resource.uri).toContain('text/1');
expect(result.content[2].text).toContain('URI');
});
it('should return blob resource reference', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceReferenceTool(mockServer);
const handler = handlers.get('get-resource-reference')!;
const result = await handler({ resourceType: 'Blob', resourceId: 5 });
expect(result.content[1].resource.uri).toContain('blob/5');
});
it('should reject invalid resource type', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceReferenceTool(mockServer);
const handler = handlers.get('get-resource-reference')!;
await expect(handler({ resourceType: 'Invalid', resourceId: 1 })).rejects.toThrow(
'Invalid resourceType'
);
});
it('should reject invalid resource ID', async () => {
const { mockServer, handlers } = createMockServer();
registerGetResourceReferenceTool(mockServer);
const handler = handlers.get('get-resource-reference')!;
await expect(handler({ resourceType: 'Text', resourceId: -1 })).rejects.toThrow(
'Invalid resourceId'
);
await expect(handler({ resourceType: 'Text', resourceId: 0 })).rejects.toThrow(
'Invalid resourceId'
);
await expect(handler({ resourceType: 'Text', resourceId: 1.5 })).rejects.toThrow(
'Invalid resourceId'
);
});
});
describe('toggle-simulated-logging', () => {
it('should start logging when not active', async () => {
const { mockServer, handlers } = createMockServer();
registerToggleSimulatedLoggingTool(mockServer);
const handler = handlers.get('toggle-simulated-logging')!;
const result = await handler({}, { sessionId: 'test-session-1' });
expect(result.content[0].text).toContain('Started');
expect(result.content[0].text).toContain('test-session-1');
});
it('should stop logging when already active', async () => {
const { mockServer, handlers } = createMockServer();
registerToggleSimulatedLoggingTool(mockServer);
const handler = handlers.get('toggle-simulated-logging')!;
// First call starts logging
await handler({}, { sessionId: 'test-session-2' });
// Second call stops logging
const result = await handler({}, { sessionId: 'test-session-2' });
expect(result.content[0].text).toContain('Stopped');
expect(result.content[0].text).toContain('test-session-2');
});
it('should handle undefined sessionId', async () => {
const { mockServer, handlers } = createMockServer();
registerToggleSimulatedLoggingTool(mockServer);
const handler = handlers.get('toggle-simulated-logging')!;
const result = await handler({}, {});
expect(result.content[0].text).toContain('Started');
});
});
describe('toggle-subscriber-updates', () => {
it('should start updates when not active', async () => {
const { mockServer, handlers } = createMockServer();
registerToggleSubscriberUpdatesTool(mockServer);
const handler = handlers.get('toggle-subscriber-updates')!;
const result = await handler({}, { sessionId: 'sub-session-1' });
expect(result.content[0].text).toContain('Started');
expect(result.content[0].text).toContain('sub-session-1');
});
it('should stop updates when already active', async () => {
const { mockServer, handlers } = createMockServer();
registerToggleSubscriberUpdatesTool(mockServer);
const handler = handlers.get('toggle-subscriber-updates')!;
// First call starts updates
await handler({}, { sessionId: 'sub-session-2' });
// Second call stops updates
const result = await handler({}, { sessionId: 'sub-session-2' });
expect(result.content[0].text).toContain('Stopped');
expect(result.content[0].text).toContain('sub-session-2');
});
});
describe('trigger-sampling-request', () => {
it('should not register when client does not support sampling', () => {
const { mockServer } = createMockServer();
registerTriggerSamplingRequestTool(mockServer);
// Tool should not be registered since mock server returns empty capabilities
expect(mockServer.registerTool).not.toHaveBeenCalled();
});
it('should register when client supports sampling', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ sampling: {} })),
},
} as unknown as McpServer;
registerTriggerSamplingRequestTool(mockServer);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'trigger-sampling-request',
expect.objectContaining({
title: 'Trigger Sampling Request Tool',
description: expect.stringContaining('Sampling'),
}),
expect.any(Function)
);
});
it('should send sampling request and return result', async () => {
const handlers: Map<string, Function> = new Map();
const mockSendRequest = vi.fn().mockResolvedValue({
model: 'test-model',
content: { type: 'text', text: 'LLM response' },
});
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ sampling: {} })),
},
} as unknown as McpServer;
registerTriggerSamplingRequestTool(mockServer);
const handler = handlers.get('trigger-sampling-request')!;
const result = await handler(
{ prompt: 'Test prompt', maxTokens: 50 },
{ sendRequest: mockSendRequest }
);
expect(mockSendRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'sampling/createMessage',
params: expect.objectContaining({
maxTokens: 50,
}),
}),
expect.anything()
);
expect(result.content[0].text).toContain('LLM sampling result');
});
});
describe('trigger-elicitation-request', () => {
it('should not register when client does not support elicitation', () => {
const { mockServer } = createMockServer();
registerTriggerElicitationRequestTool(mockServer);
expect(mockServer.registerTool).not.toHaveBeenCalled();
});
it('should register when client supports elicitation', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: {} })),
},
} as unknown as McpServer;
registerTriggerElicitationRequestTool(mockServer);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'trigger-elicitation-request',
expect.objectContaining({
title: 'Trigger Elicitation Request Tool',
description: expect.stringContaining('Elicitation'),
}),
expect.any(Function)
);
});
it('should handle accept action with user content', async () => {
const handlers: Map<string, Function> = new Map();
const mockSendRequest = vi.fn().mockResolvedValue({
action: 'accept',
content: {
name: 'John Doe',
check: true,
email: 'john@example.com',
},
});
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: {} })),
},
} as unknown as McpServer;
registerTriggerElicitationRequestTool(mockServer);
const handler = handlers.get('trigger-elicitation-request')!;
const result = await handler({}, { sendRequest: mockSendRequest });
expect(result.content[0].text).toContain('✅');
expect(result.content[0].text).toContain('provided');
expect(result.content[1].text).toContain('John Doe');
});
it('should handle decline action', async () => {
const handlers: Map<string, Function> = new Map();
const mockSendRequest = vi.fn().mockResolvedValue({
action: 'decline',
});
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: {} })),
},
} as unknown as McpServer;
registerTriggerElicitationRequestTool(mockServer);
const handler = handlers.get('trigger-elicitation-request')!;
const result = await handler({}, { sendRequest: mockSendRequest });
expect(result.content[0].text).toContain('❌');
expect(result.content[0].text).toContain('declined');
});
it('should handle cancel action', async () => {
const handlers: Map<string, Function> = new Map();
const mockSendRequest = vi.fn().mockResolvedValue({
action: 'cancel',
});
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ elicitation: {} })),
},
} as unknown as McpServer;
registerTriggerElicitationRequestTool(mockServer);
const handler = handlers.get('trigger-elicitation-request')!;
const result = await handler({}, { sendRequest: mockSendRequest });
expect(result.content[0].text).toContain('⚠️');
expect(result.content[0].text).toContain('cancelled');
});
});
describe('get-roots-list', () => {
it('should not register when client does not support roots', () => {
const { mockServer } = createMockServer();
registerGetRootsListTool(mockServer);
expect(mockServer.registerTool).not.toHaveBeenCalled();
});
it('should register when client supports roots', () => {
const handlers: Map<string, Function> = new Map();
const mockServer = {
registerTool: vi.fn((name: string, config: any, handler: Function) => {
handlers.set(name, handler);
}),
server: {
getClientCapabilities: vi.fn(() => ({ roots: {} })),
},
} as unknown as McpServer;
registerGetRootsListTool(mockServer);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'get-roots-list',
expect.objectContaining({
title: 'Get Roots List Tool',
description: expect.stringContaining('roots'),
}),
expect.any(Function)
);
});
});
describe('gzip-file-as-resource', () => {
it('should compress data URI and return resource link', async () => {
const registeredResources: any[] = [];
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn((...args) => {
registeredResources.push(args);
}),
} as unknown as McpServer;
// Get the handler
let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);
registerGZipFileAsResourceTool(mockServer);
// Create a data URI with test content
const testContent = 'Hello, World!';
const dataUri = `data:text/plain;base64,${Buffer.from(testContent).toString('base64')}`;
const result = await handler!(
{ name: 'test.txt.gz', data: dataUri, outputType: 'resourceLink' }
);
expect(result.content[0].type).toBe('resource_link');
expect(result.content[0].uri).toContain('test.txt.gz');
});
it('should return resource directly when outputType is resource', async () => {
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
} as unknown as McpServer;
let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);
registerGZipFileAsResourceTool(mockServer);
const testContent = 'Test content for compression';
const dataUri = `data:text/plain;base64,${Buffer.from(testContent).toString('base64')}`;
const result = await handler!(
{ name: 'output.gz', data: dataUri, outputType: 'resource' }
);
expect(result.content[0].type).toBe('resource');
expect(result.content[0].resource.mimeType).toBe('application/gzip');
expect(result.content[0].resource.blob).toBeDefined();
});
it('should reject unsupported URL protocols', async () => {
const mockServer = {
registerTool: vi.fn(),
registerResource: vi.fn(),
} as unknown as McpServer;
let handler: Function | null = null;
(mockServer.registerTool as any).mockImplementation(
(name: string, config: any, h: Function) => {
handler = h;
}
);
registerGZipFileAsResourceTool(mockServer);
await expect(
handler!({ name: 'test.gz', data: 'ftp://example.com/file.txt', outputType: 'resource' })
).rejects.toThrow('Unsupported URL protocol');
});
});
});

View File

@@ -0,0 +1,44 @@
# Everything Server Architecture
**Architecture
| [Project Structure](structure.md)
| [Startup Process](startup.md)
| [Server Features](features.md)
| [Extension Points](extension.md)
| [How It Works](how-it-works.md)**
This documentation summarizes the current layout and runtime architecture of the `src/everything` package.
It explains how the server starts, how transports are wired, where tools, prompts, and resources are registered, and how to extend the system.
## Highlevel Overview
### Purpose
A minimal, modular MCP server showcasing core Model Context Protocol features. It exposes simple tools, prompts, and resources, and can be run over multiple transports (STDIO, SSE, and Streamable HTTP).
### Design
A small “server factory” constructs the MCP server and registers features.
Transports are separate entry points that create/connect the server and handle network concerns.
Tools, prompts, and resources are organized in their own submodules.
### Multiclient
The server supports multiple concurrent clients. Tracking per session data is demonstrated with
resource subscriptions and simulated logging.
## Build and Distribution
- TypeScript sources are compiled into `dist/` via `npm run build`.
- The `build` script copies `docs/` into `dist/` so instruction files ship alongside the compiled server.
- The CLI bin is configured in `package.json` as `mcp-server-everything``dist/index.js`.
## [Project Structure](structure.md)
## [Startup Process](startup.md)
## [Server Features](features.md)
## [Extension Points](extension.md)
## [How It Works](how-it-works.md)

View File

@@ -0,0 +1,23 @@
# Everything Server - Extension Points
**[Architecture](architecture.md)
| [Project Structure](structure.md)
| [Startup Process](startup.md)
| [Server Features](features.md)
| Extension Points
| [How It Works](how-it-works.md)**
## Adding Tools
- Create a new file under `tools/` with your `registerXTool(server)` function that registers the tool via `server.registerTool(...)`.
- Export and call it from `tools/index.ts` inside `registerTools(server)`.
## Adding Prompts
- Create a new file under `prompts/` with your `registerXPrompt(server)` function that registers the prompt via `server.registerPrompt(...)`.
- Export and call it from `prompts/index.ts` inside `registerPrompts(server)`.
## Adding Resources
- Create a new file under `resources/` with your `registerXResources(server)` function using `server.registerResource(...)` (optionally with `ResourceTemplate`).
- Export and call it from `resources/index.ts` inside `registerResources(server)`.

View File

@@ -0,0 +1,103 @@
# Everything Server - Features
**[Architecture](architecture.md)
| [Project Structure](structure.md)
| [Startup Process](startup.md)
| Server Features
| [Extension Points](extension.md)
| [How It Works](how-it-works.md)**
## Tools
- `echo` (tools/echo.ts): Echoes the provided `message: string`. Uses Zod to validate inputs.
- `get-annotated-message` (tools/get-annotated-message.ts): Returns a `text` message annotated with `priority` and `audience` based on `messageType` (`error`, `success`, or `debug`); can optionally include an annotated `image`.
- `get-env` (tools/get-env.ts): Returns all environment variables from the running process as pretty-printed JSON text.
- `get-resource-links` (tools/get-resource-links.ts): Returns an intro `text` block followed by multiple `resource_link` items. For a requested `count` (110), alternates between dynamic Text and Blob resources using URIs from `resources/templates.ts`.
- `get-resource-reference` (tools/get-resource-reference.ts): Accepts `resourceType` (`text` or `blob`) and `resourceId` (positive integer). Returns a concrete `resource` content block (with its `uri`, `mimeType`, and data) with surrounding explanatory `text`.
- `get-roots-list` (tools/get-roots-list.ts): Returns the last list of roots sent by the client.
- `gzip-file-as-resource` (tools/gzip-file-as-resource.ts): Accepts a `name` and `data` (URL or data URI), fetches the data subject to size/time/domain constraints, compresses it, registers it as a session resource at `demo://resource/session/<name>` with `mimeType: application/gzip`, and returns either a `resource_link` (default) or an inline `resource` depending on `outputType`.
- `get-structured-content` (tools/get-structured-content.ts): Demonstrates structured responses. Accepts `location` input and returns both backwardcompatible `content` (a `text` block containing JSON) and `structuredContent` validated by an `outputSchema` (temperature, conditions, humidity).
- `get-sum` (tools/get-sum.ts): For two numbers `a` and `b` calculates and returns their sum. Uses Zod to validate inputs.
- `get-tiny-image` (tools/get-tiny-image.ts): Returns a tiny PNG MCP logo as an `image` content item with brief descriptive text before and after.
- `trigger-long-running-operation` (tools/trigger-trigger-long-running-operation.ts): Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client.
- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts): Starts or stops simulated, randomleveled logging for the invoking session. Respects the clients selected minimum logging level.
- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to.
- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload.
- `simulate-research-query` (tools/simulate-research-query.ts): Demonstrates MCP Tasks (SEP-1686) with a simulated multi-stage research operation. Accepts `topic` and `ambiguous` parameters. Returns a task that progresses through stages with status updates. If `ambiguous` is true and client supports elicitation, sends an elicitation request directly to gather clarification before completing.
- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts): Demonstrates bidirectional tasks where the server sends a sampling request that the client executes as a background task. Server polls for status and retrieves the LLM result when complete. Requires client to support `tasks.requests.sampling.createMessage`.
- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts): Demonstrates bidirectional tasks where the server sends an elicitation request that the client executes as a background task. Server polls while waiting for user input. Requires client to support `tasks.requests.elicitation.create`.
## Prompts
- `simple-prompt` (prompts/simple.ts): No-argument prompt that returns a static user message.
- `args-prompt` (prompts/args.ts): Two-argument prompt with `city` (required) and `state` (optional) used to compose a question.
- `completable-prompt` (prompts/completions.ts): Demonstrates argument auto-completions with the SDKs `completable` helper; `department` completions drive context-aware `name` suggestions.
- `resource-prompt` (prompts/resource.ts): Accepts `resourceType` ("Text" or "Blob") and `resourceId` (string convertible to integer) and returns messages that include an embedded dynamic resource of the selected type generated via `resources/templates.ts`.
## Resources
- Dynamic Text: `demo://resource/dynamic/text/{index}` (content generated on the fly)
- Dynamic Blob: `demo://resource/dynamic/blob/{index}` (base64 payload generated on the fly)
- Static Documents: `demo://resource/static/document/<filename>` (serves files from `src/everything/docs/` as static file-based resources)
- Session Scoped: `demo://resource/session/<name>` (per-session resources registered dynamically; available only for the lifetime of the session)
## Resource Subscriptions and Notifications
- Simulated update notifications are optin and off by default.
- Clients may subscribe/unsubscribe to resource URIs using the MCP `resources/subscribe` and `resources/unsubscribe` requests.
- Use the `toggle-subscriber-updates` tool to start/stop a persession interval that emits `notifications/resources/updated { uri }` only for URIs that session has subscribed to.
- Multiple concurrent clients are supported; each clients subscriptions are tracked per session and notifications are delivered independently via the server instance associated with that session.
## Simulated Logging
- Simulated logging is available but off by default.
- Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency) per session.
- Clients can control the minimum level they receive via the standard MCP `logging/setLevel` request.
## Tasks (SEP-1686)
The server advertises support for MCP Tasks, enabling long-running operations with status tracking:
- **Capabilities advertised**: `tasks.list`, `tasks.cancel`, `tasks.requests.tools.call`
- **Task Store**: Uses `InMemoryTaskStore` from SDK experimental for task lifecycle management
- **Message Queue**: Uses `InMemoryTaskMessageQueue` for task-related messaging
### Task Lifecycle
1. Client calls `tools/call` with `task: true` parameter
2. Server returns `CreateTaskResult` with `taskId` instead of immediate result
3. Client polls `tasks/get` to check status and receive `statusMessage` updates
4. When status is `completed`, client calls `tasks/result` to retrieve the final result
### Task Statuses
- `working`: Task is actively processing
- `input_required`: Task needs additional input (server sends elicitation request directly)
- `completed`: Task finished successfully
- `failed`: Task encountered an error
- `cancelled`: Task was cancelled by client
### Demo Tools
**Server-side tasks (client calls server):**
Use the `simulate-research-query` tool to exercise the full task lifecycle. Set `ambiguous: true` to trigger elicitation - the server will send an `elicitation/create` request directly and await the response before completing.
**Client-side tasks (server calls client):**
Use `trigger-sampling-request-async` or `trigger-elicitation-request-async` to demonstrate bidirectional tasks where the server sends requests that the client executes as background tasks. These require the client to advertise `tasks.requests.sampling.createMessage` or `tasks.requests.elicitation.create` capabilities respectively.
### Bidirectional Task Flow
MCP Tasks are bidirectional - both server and client can be task executors:
| Direction | Request Type | Task Executor | Demo Tool |
| ---------------- | ------------------------ | ------------- | ----------------------------------- |
| Client -> Server | `tools/call` | Server | `simulate-research-query` |
| Server -> Client | `sampling/createMessage` | Client | `trigger-sampling-request-async` |
| Server -> Client | `elicitation/create` | Client | `trigger-elicitation-request-async` |
For client-side tasks:
1. Server sends request with task metadata (e.g., `params.task.ttl`)
2. Client creates task and returns `CreateTaskResult` with `taskId`
3. Server polls `tasks/get` for status updates
4. When complete, server calls `tasks/result` to retrieve the result

View File

@@ -0,0 +1,45 @@
# Everything Server - How It Works
**[Architecture](architecture.md)
| [Project Structure](structure.md)
| [Startup Process](startup.md)
| [Server Features](features.md)
| [Extension Points](extension.md)
| How It Works**
# Conditional Tool Registration
### Module: `server/index.ts`
- Some tools require client support for the capability they demonstrate. These are:
- `get-roots-list`
- `trigger-elicitation-request`
- `trigger-sampling-request`
- Client capabilities aren't known until after initilization handshake is complete.
- Most tools are registered immediately during the Server Factory execution, prior to client connection.
- To defer registration of these commands until client capabilities are known, a `registerConditionalTools(server)` function is invoked from an `onintitialized` handler.
## Resource Subscriptions
### Module: `resources/subscriptions.ts`
- Tracks subscribers per URI: `Map<uri, Set<sessionId>>`.
- Installs handlers via `setSubscriptionHandlers(server)` to process subscribe/unsubscribe requests and keep the map updated.
- Updates are started/stopped on demand by the `toggle-subscriber-updates` tool, which calls `beginSimulatedResourceUpdates(server, sessionId)` and `stopSimulatedResourceUpdates(sessionId)`.
- `cleanup(sessionId?)` calls `stopSimulatedResourceUpdates(sessionId)` to clear intervals and remove sessionscoped state.
## Sessionscoped Resources
### Module: `resources/session.ts`
- `getSessionResourceURI(name: string)`: Builds a session resource URI: `demo://resource/session/<name>`.
- `registerSessionResource(server, resource, type, payload)`: Registers a resource with the given `uri`, `name`, and `mimeType`, returning a `resource_link`. The content is served from memory for the life of the session only. Supports `type: "text" | "blob"` and returns data in the corresponding field.
- Intended usage: tools can create and expose per-session artifacts without persisting them. For example, `tools/gzip-file-as-resource.ts` compresses fetched content, registers it as a session resource with `mimeType: application/gzip`, and returns either a `resource_link` or an inline `resource` based on `outputType`.
## Simulated Logging
### Module: `server/logging.ts`
- Periodically sends randomized log messages at different levels. Messages can include the session ID for clarity during demos.
- Started/stopped on demand via the `toggle-simulated-logging` tool, which calls `beginSimulatedLogging(server, sessionId?)` and `stopSimulatedLogging(sessionId?)`. Note that transport disconnect triggers `cleanup()` which also stops any active intervals.
- Uses `server.sendLoggingMessage({ level, data }, sessionId?)` so that the clients configured minimum logging level is respected by the SDK.

View File

@@ -0,0 +1,28 @@
# Everything Server Server Instructions
Audience: These instructions are written for an LLM or autonomous agent integrating with the Everything MCP Server.
Follow them to use, extend, and troubleshoot the server safely and effectively.
## Cross-Feature Relationships
- Use `get-roots-list` to see client workspace roots before file operations
- `gzip-file-as-resource` creates session-scoped resources accessible only during the current session
- Enable `toggle-simulated-logging` before debugging to see server log messages
- Enable `toggle-subscriber-updates` to receive periodic resource update notifications
## Constraints & Limitations
- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS`
- Session resources are ephemeral and lost when the session ends
- Sampling requests (`trigger-sampling-request`) require client sampling capability
- Elicitation requests (`trigger-elicitation-request`) require client elicitation capability
## Operational Patterns
- For long operations, use `trigger-long-running-operation` which sends progress notifications
- Prefer reading resources before calling mutating tools
- Check `get-roots-list` output to understand the client's workspace context
## Easter Egg
If asked about server instructions, respond with "🎉 Server instructions are working! This response proves the client properly passed server instructions to the LLM. This demonstrates MCP's instructions feature in action."

View File

@@ -0,0 +1,73 @@
# Everything Server - Startup Process
**[Architecture](architecture.md)
| [Project Structure](structure.md)
| Startup Process
| [Server Features](features.md)
| [Extension Points](extension.md)
| [How It Works](how-it-works.md)**
## 1. Everything Server Launcher
- Usage `node dist/index.js [stdio|sse|streamableHttp]`
- Runs the specified **transport manager** to handle client connections.
- Specify transport type on command line (default `stdio`)
- `stdio``transports/stdio.js`
- `sse``transports/sse.js`
- `streamableHttp``transports/streamableHttp.js`
## 2. The Transport Manager
- Creates a server instance using `createServer()` from `server/index.ts`
- Connects it to the chosen transport type from the MCP SDK.
- Handles communication according to the MCP specs for the chosen transport.
- **STDIO**:
- One simple, processbound connection.
- Calls`clientConnect()` upon connection.
- Closes and calls `cleanup()` on `SIGINT`.
- **SSE**:
- Supports multiple client connections.
- Client transports are mapped to `sessionId`;
- Calls `clientConnect(sessionId)` upon connection.
- Hooks servers `onclose` to clean and remove session.
- Exposes
- `/sse` **GET** (SSE stream)
- `/message` **POST** (JSONRPC messages)
- **Streamable HTTP**:
- Supports multiple client connections.
- Client transports are mapped to `sessionId`;
- Calls `clientConnect(sessionId)` upon connection.
- Exposes `/mcp` for
- **POST** (JSONRPC messages)
- **GET** (SSE stream)
- **DELETE** (termination)
- Uses an event store for resumability and stores transports by `sessionId`.
- Calls `cleanup(sessionId)` on **DELETE**.
## 3. The Server Factory
- Invoke `createServer()` from `server/index.ts`
- Creates a new `McpServer` instance with
- **Capabilities**:
- `tools: {}`
- `logging: {}`
- `prompts: {}`
- `resources: { subscribe: true }`
- **Server Instructions**
- Loaded from the docs folder (`server-instructions.md`).
- **Registrations**
- Registers **tools** via `registerTools(server)`.
- Registers **resources** via `registerResources(server)`.
- Registers **prompts** via `registerPrompts(server)`.
- **Other Request Handlers**
- Sets up resource subscription handlers via `setSubscriptionHandlers(server)`.
- Roots list change handler is added post-connection via
- **Returns**
- The `McpServer` instance
- A `clientConnect(sessionId)` callback that enables post-connection setup
- A `cleanup(sessionId?)` callback that stops any active intervals and removes any sessionscoped state
## Enabling Multiple Clients
Some of the transport managers defined in the `transports` folder can support multiple clients.
In order to do so, they must map certain data to a session identifier.

View File

@@ -0,0 +1,182 @@
# Everything Server - Project Structure
**[Architecture](architecture.md)
| Project Structure
| [Startup Process](startup.md)
| [Server Features](features.md)
| [Extension Points](extension.md)
| [How It Works](how-it-works.md)**
```
src/everything
├── index.ts
├── AGENTS.md
├── package.json
├── docs
│ ├── architecture.md
│ ├── extension.md
│ ├── features.md
│ ├── how-it-works.md
│ ├── instructions.md
│ ├── startup.md
│ └── structure.md
├── prompts
│ ├── index.ts
│ ├── args.ts
│ ├── completions.ts
│ ├── simple.ts
│ └── resource.ts
├── resources
│ ├── index.ts
│ ├── files.ts
│ ├── session.ts
│ ├── subscriptions.ts
│ └── templates.ts
├── server
│ ├── index.ts
│ ├── logging.ts
│ └── roots.ts
├── tools
│ ├── index.ts
│ ├── echo.ts
│ ├── get-annotated-message.ts
│ ├── get-env.ts
│ ├── get-resource-links.ts
│ ├── get-resource-reference.ts
│ ├── get-roots-list.ts
│ ├── get-structured-content.ts
│ ├── get-sum.ts
│ ├── get-tiny-image.ts
│ ├── gzip-file-as-resource.ts
│ ├── toggle-simulated-logging.ts
│ ├── toggle-subscriber-updates.ts
│ ├── trigger-elicitation-request.ts
│ ├── trigger-long-running-operation.ts
│ └── trigger-sampling-request.ts
└── transports
├── sse.ts
├── stdio.ts
└── streamableHttp.ts
```
# Project Contents
## `src/everything`:
### `index.ts`
- CLI entry point that selects and runs a specific transport module based on the first CLI argument: `stdio`, `sse`, or `streamableHttp`.
### `AGENTS.md`
- Directions for Agents/LLMs explaining coding guidelines and how to appropriately extend the server.
### `package.json`
- Package metadata and scripts:
- `build`: TypeScript compile to `dist/`, copies `docs/` into `dist/` and marks the compiled entry scripts as executable.
- `start:stdio`, `start:sse`, `start:streamableHttp`: Run built transports from `dist/`.
- Declares dependencies on `@modelcontextprotocol/sdk`, `express`, `cors`, `zod`, etc.
### `docs/`
- `architecture.md`
- This document.
- `server-instructions.md`
- Humanreadable instructions intended to be passed to the client/LLM as for guidance on server use. Loaded by the server at startup and returned in the "initialize" exchange.
### `prompts/`
- `index.ts`
- `registerPrompts(server)` orchestrator; delegates to prompt factory/registration methods from in individual prompt files.
- `simple.ts`
- Registers `simple-prompt`: a prompt with no arguments that returns a single user message.
- `args.ts`
- Registers `args-prompt`: a prompt with two arguments (`city` required, `state` optional) used to compose a message.
- `completions.ts`
- Registers `completable-prompt`: a prompt whose arguments support server-driven completions using the SDKs `completable(...)` helper (e.g., completing `department` and context-aware `name`).
- `resource.ts`
- Exposes `registerEmbeddedResourcePrompt(server)` which registers `resource-prompt` — a prompt that accepts `resourceType` ("Text" or "Blob") and `resourceId` (integer), and embeds a dynamically generated resource of the requested type within the returned messages. Internally reuses helpers from `resources/templates.ts`.
### `resources/`
- `index.ts`
- `registerResources(server)` orchestrator; delegates to resource factory/registration methods from individual resource files.
- `templates.ts`
- Registers two dynamic, templatedriven resources using `ResourceTemplate`:
- Text: `demo://resource/dynamic/text/{index}` (MIME: `text/plain`)
- Blob: `demo://resource/dynamic/blob/{index}` (MIME: `application/octet-stream`, Base64 payload)
- The `{index}` path variable must be a finite positive integer. Content is generated on demand with a timestamp.
- Exposes helpers `textResource(uri, index)`, `textResourceUri(index)`, `blobResource(uri, index)`, and `blobResourceUri(index)` so other modules can construct and embed dynamic resources directly (e.g., from prompts).
- `files.ts`
- Registers static file-based resources for each file in the `docs/` folder.
- URIs follow the pattern: `demo://resource/static/document/<filename>`.
- Serves markdown files as `text/markdown`, `.txt` as `text/plain`, `.json` as `application/json`, others default to `text/plain`.
### `server/`
- `index.ts`
- Server factory that creates an `McpServer` with declared capabilities, loads server instructions, and registers tools, prompts, and resources.
- Sets resource subscription handlers via `setSubscriptionHandlers(server)`.
- Exposes `{ server, cleanup }` to the chosen transport. Cleanup stops any running intervals in the server when the transport disconnects.
- `logging.ts`
- Implements simulated logging. Periodically sends randomized log messages at various levels to the connected client session. Started/stopped on demand via a dedicated tool.
### `tools/`
- `index.ts`
- `registerTools(server)` orchestrator; delegates to tool factory/registration methods in individual tool files.
- `echo.ts`
- Registers an `echo` tool that takes a message and returns `Echo: {message}`.
- `get-annotated-message.ts`
- Registers an `annotated-message` tool which demonstrates annotated content items by emitting a primary `text` message with `annotations` that vary by `messageType` (`"error" | "success" | "debug"`), and optionally includes an annotated `image` (tiny PNG) when `includeImage` is true.
- `get-env.ts`
- Registers a `get-env` tool that returns the current process environment variables as formatted JSON text; useful for debugging configuration.
- `get-resource-links.ts`
- Registers a `get-resource-links` tool that returns an intro `text` block followed by multiple `resource_link` items.
- `get-resource-reference.ts`
- Registers a `get-resource-reference` tool that returns a reference for a selected dynamic resource.
- `get-roots-list.ts`
- Registers a `get-roots-list` tool that returns the last list of roots sent by the client.
- `gzip-file-as-resource.ts`
- Registers a `gzip-file-as-resource` tool that fetches content from a URL or data URI, compresses it, and then either:
- returns a `resource_link` to a session-scoped resource (default), or
- returns an inline `resource` with the gzipped data. The resource will be still discoverable for the duration of the session via `resources/list`.
- Uses `resources/session.ts` to register the gzipped blob as a per-session resource at a URI like `demo://resource/session/<name>` with `mimeType: application/gzip`.
- Environment controls:
- `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB)
- `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000)
- `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed)
- `trigger-elicitation-request.ts`
- Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result.
- `trigger-sampling-request.ts`
- Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result.
- `get-structured-content.ts`
- Registers a `get-structured-content` tool that demonstrates structuredContent block responses.
- `get-sum.ts`
- Registers an `get-sum` tool with a Zod input schema that sums two numbers `a` and `b` and returns the result.
- `get-tiny-image.ts`
- Registers a `get-tiny-image` tool, which returns a tiny PNG MCP logo as an `image` content item, along with surrounding descriptive `text` items.
- `trigger-long-running-operation.ts`
- Registers a `long-running-operation` tool that simulates a long-running task over a specified `duration` (seconds) and number of `steps`; emits `notifications/progress` updates when the client supplies a `progressToken`.
- `toggle-simulated-logging.ts`
- Registers a `toggle-simulated-logging` tool, which starts or stops simulated logging for the invoking session.
- `toggle-subscriber-updates.ts`
- Registers a `toggle-subscriber-updates` tool, which starts or stops simulated resource subscription update checks for the invoking session.
### `transports/`
- `stdio.ts`
- Starts a `StdioServerTransport`, created the server via `createServer()`, and connects it.
- Handles `SIGINT` to close cleanly and calls `cleanup()` to remove any live intervals.
- `sse.ts`
- Express server exposing:
- `GET /sse` to establish an SSE connection per session.
- `POST /message` for client messages.
- Manages multiple connected clients via a transport map.
- Starts an `SSEServerTransport`, created the server via `createServer()`, and connects it to a new transport.
- On server disconnect, calls `cleanup()` to remove any live intervals.
- `streamableHttp.ts`
- Express server exposing a single `/mcp` endpoint for POST (JSONRPC), GET (SSE stream), and DELETE (session termination) using `StreamableHTTPServerTransport`.
- Uses an `InMemoryEventStore` for resumable sessions and tracks transports by `sessionId`.
- Connects a fresh server instance on initialization POST and reuses the transport for subsequent requests.

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
// Parse command line arguments first
const args = process.argv.slice(2);
const scriptName = args[0] || "stdio";
async function run() {
try {
// Dynamically import only the requested module to prevent all modules from initializing
switch (scriptName) {
case "stdio":
// Import and run the default server
await import("./transports/stdio.js");
break;
case "sse":
// Import and run the SSE server
await import("./transports/sse.js");
break;
case "streamableHttp":
// Import and run the streamable HTTP server
await import("./transports/streamableHttp.js");
break;
default:
console.error(`-`.repeat(53));
console.error(` Everything Server Launcher`);
console.error(` Usage: node ./index.js [stdio|sse|streamableHttp]`);
console.error(` Default transport: stdio`);
console.error(`-`.repeat(53));
console.error(`Unknown transport: ${scriptName}`);
console.log("Available transports:");
console.log("- stdio");
console.log("- sse");
console.log("- streamableHttp");
process.exit(1);
}
} catch (error) {
console.error("Error running script:", error);
process.exit(1);
}
}
await run();

View File

@@ -0,0 +1,49 @@
{
"name": "@modelcontextprotocol/server-everything",
"version": "2.0.0",
"description": "MCP server that exercises all the features of the MCP protocol",
"license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-everything",
"author": "Model Context Protocol a Series of LF Projects, LLC.",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/servers/issues",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/servers.git"
},
"type": "module",
"bin": {
"mcp-server-everything": "dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && shx cp -r docs dist/ && shx chmod +x dist/*.js",
"prepare": "npm run build",
"watch": "tsc --watch",
"start:stdio": "node dist/index.js stdio",
"start:sse": "node dist/index.js sse",
"start:streamableHttp": "node dist/index.js streamableHttp",
"prettier:fix": "prettier --write .",
"prettier:check": "prettier --check .",
"test": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"cors": "^2.8.5",
"express": "^5.2.1",
"jszip": "^3.10.1",
"zod": "^3.25.0",
"zod-to-json-schema": "^3.23.5"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@vitest/coverage-v8": "^2.1.8",
"shx": "^0.3.4",
"typescript": "^5.6.2",
"prettier": "^2.8.8",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
/**
* Register a prompt with arguments
* - Two arguments, one required and one optional
* - Combines argument values in the returned prompt
*
* @param server
*/
export const registerArgumentsPrompt = (server: McpServer) => {
// Prompt arguments
const promptArgsSchema = {
city: z.string().describe("Name of the city"),
state: z.string().describe("Name of the state").optional(),
};
// Register the prompt
server.registerPrompt(
"args-prompt",
{
title: "Arguments Prompt",
description: "A prompt with two arguments, one required and one optional",
argsSchema: promptArgsSchema,
},
(args) => {
const location = `${args?.city}${args?.state ? `, ${args?.state}` : ""}`;
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `What's weather in ${location}?`,
},
},
],
};
}
);
};

View File

@@ -0,0 +1,64 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
/**
* Register a prompt with completable arguments
* - Two required arguments, both with completion handlers
* - First argument value will be included in context for second argument
* - Allows second argument to depend on the first argument value
*
* @param server
*/
export const registerPromptWithCompletions = (server: McpServer) => {
// Prompt arguments
const promptArgsSchema = {
department: completable(
z.string().describe("Choose the department."),
(value) => {
return ["Engineering", "Sales", "Marketing", "Support"].filter((d) =>
d.startsWith(value)
);
}
),
name: completable(
z
.string()
.describe("Choose a team member to lead the selected department."),
(value, context) => {
const department = context?.arguments?.["department"];
if (department === "Engineering") {
return ["Alice", "Bob", "Charlie"].filter((n) => n.startsWith(value));
} else if (department === "Sales") {
return ["David", "Eve", "Frank"].filter((n) => n.startsWith(value));
} else if (department === "Marketing") {
return ["Grace", "Henry", "Iris"].filter((n) => n.startsWith(value));
} else if (department === "Support") {
return ["John", "Kim", "Lee"].filter((n) => n.startsWith(value));
}
return [];
}
),
};
// Register the prompt
server.registerPrompt(
"completable-prompt",
{
title: "Team Management",
description: "First argument choice narrows values for second argument.",
argsSchema: promptArgsSchema,
},
({ department, name }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Please promote ${name} to the head of the ${department} team.`,
},
},
],
})
);
};

View File

@@ -0,0 +1,17 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSimplePrompt } from "./simple.js";
import { registerArgumentsPrompt } from "./args.js";
import { registerPromptWithCompletions } from "./completions.js";
import { registerEmbeddedResourcePrompt } from "./resource.js";
/**
* Register the prompts with the MCP server.
*
* @param server
*/
export const registerPrompts = (server: McpServer) => {
registerSimplePrompt(server);
registerArgumentsPrompt(server);
registerPromptWithCompletions(server);
registerEmbeddedResourcePrompt(server);
};

View File

@@ -0,0 +1,93 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
resourceTypeCompleter,
resourceIdForPromptCompleter,
} from "../resources/templates.js";
import {
textResource,
textResourceUri,
blobResourceUri,
blobResource,
RESOURCE_TYPE_BLOB,
RESOURCE_TYPE_TEXT,
RESOURCE_TYPES,
} from "../resources/templates.js";
/**
* Register a prompt with an embedded resource reference
* - Takes a resource type and id
* - Returns the corresponding dynamically created resource
*
* @param server
*/
export const registerEmbeddedResourcePrompt = (server: McpServer) => {
// Prompt arguments
const promptArgsSchema = {
resourceType: resourceTypeCompleter,
resourceId: resourceIdForPromptCompleter,
};
// Register the prompt
server.registerPrompt(
"resource-prompt",
{
title: "Resource Prompt",
description: "A prompt that includes an embedded resource reference",
argsSchema: promptArgsSchema,
},
(args) => {
// Validate resource type argument
const resourceType = args.resourceType;
if (
!RESOURCE_TYPES.includes(
resourceType as typeof RESOURCE_TYPE_TEXT | typeof RESOURCE_TYPE_BLOB
)
) {
throw new Error(
`Invalid resourceType: ${args?.resourceType}. Must be ${RESOURCE_TYPE_TEXT} or ${RESOURCE_TYPE_BLOB}.`
);
}
// Validate resourceId argument
const resourceId = Number(args?.resourceId);
if (
!Number.isFinite(resourceId) ||
!Number.isInteger(resourceId) ||
resourceId < 1
) {
throw new Error(
`Invalid resourceId: ${args?.resourceId}. Must be a finite positive integer.`
);
}
// Get resource based on the resource type
const uri =
resourceType === RESOURCE_TYPE_TEXT
? textResourceUri(resourceId)
: blobResourceUri(resourceId);
const resource =
resourceType === RESOURCE_TYPE_TEXT
? textResource(uri, resourceId)
: blobResource(uri, resourceId);
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `This prompt includes the ${resourceType} resource with id: ${resourceId}. Please analyze the following resource:`,
},
},
{
role: "user",
content: {
type: "resource",
resource: resource,
},
},
],
};
}
);
};

View File

@@ -0,0 +1,29 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
/**
* Register a simple prompt with no arguments
* - Returns the fixed text of the prompt with no modifications
*
* @param server
*/
export const registerSimplePrompt = (server: McpServer) => {
// Register the prompt
server.registerPrompt(
"simple-prompt",
{
title: "Simple Prompt",
description: "A prompt with no arguments",
},
() => ({
messages: [
{
role: "user",
content: {
type: "text",
text: "This is a simple prompt without arguments.",
},
},
],
})
);
};

View File

@@ -0,0 +1,89 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { readdirSync, readFileSync, statSync } from "fs";
/**
* Register static file resources
* - Each file in src/everything/docs is exposed as an individual static resource
* - URIs follow the pattern: "demo://static/docs/<filename>"
* - Markdown (.md) files are served as mime type "text/markdown"
* - Text (.txt) files are served as mime type "text/plain"
* - JSON (.json) files are served as mime type "application/json"
*
* @param server
*/
export const registerFileResources = (server: McpServer) => {
// Read the entries in the docs directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const docsDir = join(__dirname, "..", "docs");
let entries: string[] = [];
try {
entries = readdirSync(docsDir);
} catch (e) {
// If docs/ folder is missing or unreadable, just skip registration
return;
}
// Register each file as a static resource
for (const name of entries) {
// Only process files, not directories
const fullPath = join(docsDir, name);
try {
const st = statSync(fullPath);
if (!st.isFile()) continue;
} catch {
continue;
}
// Prepare file resource info
const uri = `demo://resource/static/document/${encodeURIComponent(name)}`;
const mimeType = getMimeType(name);
const description = `Static document file exposed from /docs: ${name}`;
// Register file resource
server.registerResource(
name,
uri,
{ mimeType, description },
async (uri) => {
const text = readFileSafe(fullPath);
return {
contents: [
{
uri: uri.toString(),
mimeType,
text,
},
],
};
}
);
}
};
/**
* Get the mimetype based on filename
* @param fileName
*/
function getMimeType(fileName: string): string {
const lower = fileName.toLowerCase();
if (lower.endsWith(".md") || lower.endsWith(".markdown"))
return "text/markdown";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".json")) return "application/json";
return "text/plain";
}
/**
* Read a file or return an error message if it fails
* @param path
*/
function readFileSafe(path: string): string {
try {
return readFileSync(path, "utf-8");
} catch (e) {
return `Error reading file: ${path}. ${e}`;
}
}

View File

@@ -0,0 +1,36 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerResourceTemplates } from "./templates.js";
import { registerFileResources } from "./files.js";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { readFileSync } from "fs";
/**
* Register the resources with the MCP server.
* @param server
*/
export const registerResources = (server: McpServer) => {
registerResourceTemplates(server);
registerFileResources(server);
};
/**
* Reads the server instructions from the corresponding markdown file.
* Attempts to load the content of the file located in the `docs` directory.
* If the file cannot be loaded, an error message is returned instead.
*
* @return {string} The content of the server instructions file, or an error message if reading fails.
*/
export function readInstructions(): string {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const filePath = join(__dirname, "..", "docs", "instructions.md");
let instructions;
try {
instructions = readFileSync(filePath, "utf-8");
} catch (e) {
instructions = "Server instructions not loaded: " + e;
}
return instructions;
}

View File

@@ -0,0 +1,80 @@
import { McpServer, RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js";
/**
* Tracks registered session resources by URI to allow updating/removing on re-registration.
* This prevents "Resource already registered" errors when a tool creates a resource
* with the same URI multiple times during a session.
*/
const registeredResources = new Map<string, RegisteredResource>();
/**
* Generates a session-scoped resource URI string based on the provided resource name.
*
* @param {string} name - The name of the resource to create a URI for.
* @returns {string} The formatted session resource URI.
*/
export const getSessionResourceURI = (name: string): string => {
return `demo://resource/session/${name}`;
};
/**
* Registers a session-scoped resource with the provided server and returns a resource link.
*
* The registered resource is available during the life of the session only; it is not otherwise persisted.
*
* @param {McpServer} server - The server instance responsible for handling the resource registration.
* @param {Resource} resource - The resource object containing metadata such as URI, name, description, and mimeType.
* @param {"text"|"blob"} type
* @param payload
* @returns {ResourceLink} An object representing the resource link, with associated metadata.
*/
export const registerSessionResource = (
server: McpServer,
resource: Resource,
type: "text" | "blob",
payload: string
): ResourceLink => {
// Destructure resource
const { uri, name, mimeType, description, title, annotations, icons, _meta } =
resource;
// Prepare the resource content to return
// See https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-contents
const resourceContent =
type === "text"
? {
uri: uri.toString(),
mimeType,
text: payload,
}
: {
uri: uri.toString(),
mimeType,
blob: payload,
};
// Check if a resource with this URI is already registered and remove it
const existingResource = registeredResources.get(uri);
if (existingResource) {
existingResource.remove();
registeredResources.delete(uri);
}
// Register file resource
const registeredResource = server.registerResource(
name,
uri,
{ mimeType, description, title, annotations, icons, _meta },
async () => {
return {
contents: [resourceContent],
};
}
);
// Track the registered resource for potential future removal
registeredResources.set(uri, registeredResource);
return { type: "resource_link", ...resource };
};

View File

@@ -0,0 +1,171 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
SubscribeRequestSchema,
UnsubscribeRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Track subscriber session id lists by URI
const subscriptions: Map<string, Set<string | undefined>> = new Map<
string,
Set<string | undefined>
>();
// Interval to send notifications to subscribers
const subsUpdateIntervals: Map<string | undefined, NodeJS.Timeout | undefined> =
new Map<string | undefined, NodeJS.Timeout | undefined>();
/**
* Sets up the subscription and unsubscription handlers for the provided server.
*
* The function defines two request handlers:
* 1. A `Subscribe` handler that allows clients to subscribe to specific resource URIs.
* 2. An `Unsubscribe` handler that allows clients to unsubscribe from specific resource URIs.
*
* The `Subscribe` handler performs the following actions:
* - Extracts the URI and session ID from the request.
* - Logs a message acknowledging the subscription request.
* - Updates the internal tracking of subscribers for the given URI.
*
* The `Unsubscribe` handler performs the following actions:
* - Extracts the URI and session ID from the request.
* - Logs a message acknowledging the unsubscription request.
* - Removes the subscriber for the specified URI.
*
* @param {McpServer} server - The server instance to which subscription handlers will be attached.
*/
export const setSubscriptionHandlers = (server: McpServer) => {
// Set the subscription handler
server.server.setRequestHandler(
SubscribeRequestSchema,
async (request, extra) => {
// Get the URI to subscribe to
const { uri } = request.params;
// Get the session id (can be undefined for stdio)
const sessionId = extra.sessionId as string;
// Acknowledge the subscribe request
await server.sendLoggingMessage(
{
level: "info",
data: `Received Subscribe Resource request for URI: ${uri} ${
sessionId ? `from session ${sessionId}` : ""
}`,
},
sessionId
);
// Get the subscribers for this URI
const subscribers = subscriptions.has(uri)
? (subscriptions.get(uri) as Set<string>)
: new Set<string>();
subscribers.add(sessionId);
subscriptions.set(uri, subscribers);
return {};
}
);
// Set the unsubscription handler
server.server.setRequestHandler(
UnsubscribeRequestSchema,
async (request, extra) => {
// Get the URI to subscribe to
const { uri } = request.params;
// Get the session id (can be undefined for stdio)
const sessionId = extra.sessionId as string;
// Acknowledge the subscribe request
await server.sendLoggingMessage(
{
level: "info",
data: `Received Unsubscribe Resource request: ${uri} ${
sessionId ? `from session ${sessionId}` : ""
}`,
},
sessionId
);
// Remove the subscriber
if (subscriptions.has(uri)) {
const subscribers = subscriptions.get(uri) as Set<string>;
if (subscribers.has(sessionId)) subscribers.delete(sessionId);
}
return {};
}
);
};
/**
* Sends simulated resource update notifications to the subscribed client.
*
* This function iterates through all resource URIs stored in the subscriptions
* and checks if the specified session ID is subscribed to them. If so, it sends
* a notification through the provided server. If the session ID is no longer valid
* (disconnected), it removes the session ID from the list of subscribers.
*
* @param {McpServer} server - The server instance used to send notifications.
* @param {string | undefined} sessionId - The session ID of the client to check for subscriptions.
* @returns {Promise<void>} Resolves once all applicable notifications are sent.
*/
const sendSimulatedResourceUpdates = async (
server: McpServer,
sessionId: string | undefined
): Promise<void> => {
// Search all URIs for ones this client is subscribed to
for (const uri of subscriptions.keys()) {
const subscribers = subscriptions.get(uri) as Set<string | undefined>;
// If this client is subscribed, send the notification
if (subscribers.has(sessionId)) {
await server.server.notification({
method: "notifications/resources/updated",
params: { uri },
});
} else {
subscribers.delete(sessionId); // subscriber has disconnected
}
}
};
/**
* Starts the process of simulating resource updates and sending server notifications
* to the client for the resources they are subscribed to. If the update interval is
* already active, invoking this function will not start another interval.
*
* @param server
* @param sessionId
*/
export const beginSimulatedResourceUpdates = (
server: McpServer,
sessionId: string | undefined
) => {
if (!subsUpdateIntervals.has(sessionId)) {
// Send once immediately
sendSimulatedResourceUpdates(server, sessionId);
// Set the interval to send later resource update notifications to this client
subsUpdateIntervals.set(
sessionId,
setInterval(() => sendSimulatedResourceUpdates(server, sessionId), 5000)
);
}
};
/**
* Stops simulated resource updates for a given session.
*
* This function halts any active intervals associated with the provided session ID
* and removes the session's corresponding entries from resource management collections.
* Session ID can be undefined for stdio.
*
* @param {string} [sessionId]
*/
export const stopSimulatedResourceUpdates = (sessionId?: string) => {
// Remove active intervals
if (subsUpdateIntervals.has(sessionId)) {
const subsUpdateInterval = subsUpdateIntervals.get(sessionId);
clearInterval(subsUpdateInterval);
subsUpdateIntervals.delete(sessionId);
}
};

View File

@@ -0,0 +1,211 @@
import { z } from "zod";
import {
CompleteResourceTemplateCallback,
McpServer,
ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
// Resource types
export const RESOURCE_TYPE_TEXT = "Text" as const;
export const RESOURCE_TYPE_BLOB = "Blob" as const;
export const RESOURCE_TYPES: string[] = [
RESOURCE_TYPE_TEXT,
RESOURCE_TYPE_BLOB,
];
/**
* A completer function for resource types.
*
* This variable provides functionality to perform autocompletion for the resource types based on user input.
* It uses a schema description to validate the input and filters through a predefined list of resource types
* to return suggestions that start with the given input.
*
* The input value is expected to be a string representing the type of resource to fetch.
* The completion logic matches the input against available resource types.
*/
export const resourceTypeCompleter = completable(
z.string().describe("Type of resource to fetch"),
(value: string) => {
return RESOURCE_TYPES.filter((t) => t.startsWith(value));
}
);
/**
* A completer function for resource IDs as strings.
*
* The `resourceIdCompleter` accepts a string input representing the ID of a text resource
* and validates whether the provided value corresponds to an integer resource ID.
*
* NOTE: Currently, prompt arguments can only be strings since type is not field of `PromptArgument`
* Consequently, we must define it as a string and convert the argument to number before using it
* https://modelcontextprotocol.io/specification/2025-11-25/schema#promptargument
*
* If the value is a valid integer, it returns the value within an array.
* Otherwise, it returns an empty array.
*
* The input string is first transformed into a number and checked to ensure it is an integer.
* This helps validate and suggest appropriate resource IDs.
*/
export const resourceIdForPromptCompleter = completable(
z.string().describe("ID of the text resource to fetch"),
(value: string) => {
const resourceId = Number(value);
return Number.isInteger(resourceId) && resourceId > 0 ? [value] : [];
}
);
/**
* A callback function that acts as a completer for resource ID values, validating and returning
* the input value as part of a resource template.
*
* @typedef {CompleteResourceTemplateCallback}
* @param {string} value - The input string value to be evaluated as a resource ID.
* @returns {string[]} Returns an array containing the input value if it represents a positive
* integer resource ID, otherwise returns an empty array.
*/
export const resourceIdForResourceTemplateCompleter: CompleteResourceTemplateCallback =
(value: string) => {
const resourceId = Number(value);
return Number.isInteger(resourceId) && resourceId > 0 ? [value] : [];
};
const uriBase: string = "demo://resource/dynamic";
const textUriBase: string = `${uriBase}/text`;
const blobUriBase: string = `${uriBase}/blob`;
const textUriTemplate: string = `${textUriBase}/{resourceId}`;
const blobUriTemplate: string = `${blobUriBase}/{resourceId}`;
/**
* Create a dynamic text resource
* - Exposed for use by embedded resource prompt example
* @param uri
* @param resourceId
*/
export const textResource = (uri: URL, resourceId: number) => {
const timestamp = new Date().toLocaleTimeString();
return {
uri: uri.toString(),
mimeType: "text/plain",
text: `Resource ${resourceId}: This is a plaintext resource created at ${timestamp}`,
};
};
/**
* Create a dynamic blob resource
* - Exposed for use by embedded resource prompt example
* @param uri
* @param resourceId
*/
export const blobResource = (uri: URL, resourceId: number) => {
const timestamp = new Date().toLocaleTimeString();
const resourceText = Buffer.from(
`Resource ${resourceId}: This is a base64 blob created at ${timestamp}`
).toString("base64");
return {
uri: uri.toString(),
mimeType: "text/plain",
blob: resourceText,
};
};
/**
* Create a dynamic text resource URI
* - Exposed for use by embedded resource prompt example
* @param resourceId
*/
export const textResourceUri = (resourceId: number) =>
new URL(`${textUriBase}/${resourceId}`);
/**
* Create a dynamic blob resource URI
* - Exposed for use by embedded resource prompt example
* @param resourceId
*/
export const blobResourceUri = (resourceId: number) =>
new URL(`${blobUriBase}/${resourceId}`);
/**
* Parses the resource identifier from the provided URI and validates it
* against the given variables. Throws an error if the URI corresponds
* to an unknown resource or if the resource identifier is invalid.
*
* @param {URL} uri - The URI of the resource to be parsed.
* @param {Record<string, unknown>} variables - A record containing context-specific variables that include the resourceId.
* @returns {number} The parsed and validated resource identifier as an integer.
* @throws {Error} Throws an error if the URI matches unsupported base URIs or if the resourceId is invalid.
*/
const parseResourceId = (uri: URL, variables: Record<string, unknown>) => {
const uriError = `Unknown resource: ${uri.toString()}`;
if (
uri.toString().startsWith(textUriBase) &&
uri.toString().startsWith(blobUriBase)
) {
throw new Error(uriError);
} else {
const idxStr = String((variables as any).resourceId ?? "");
const idx = Number(idxStr);
if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) {
return idx;
} else {
throw new Error(uriError);
}
}
};
/**
* Register resource templates with the MCP server.
* - Text and blob resources, dynamically generated from the URI {resourceId} variable
* - Any finite positive integer is acceptable for the resourceId variable
* - List resources method will not return these resources
* - These are only accessible via template URIs
* - Both blob and text resources:
* - have content that is dynamically generated, including a timestamp
* - have different template URIs
* - Blob: "demo://resource/dynamic/blob/{resourceId}"
* - Text: "demo://resource/dynamic/text/{resourceId}"
*
* @param server
*/
export const registerResourceTemplates = (server: McpServer) => {
// Register the text resource template
server.registerResource(
"Dynamic Text Resource",
new ResourceTemplate(textUriTemplate, {
list: undefined,
complete: { resourceId: resourceIdForResourceTemplateCompleter },
}),
{
mimeType: "text/plain",
description:
"Plaintext dynamic resource fabricated from the {resourceId} variable, which must be an integer.",
},
async (uri, variables) => {
const resourceId = parseResourceId(uri, variables);
return {
contents: [textResource(uri, resourceId)],
};
}
);
// Register the blob resource template
server.registerResource(
"Dynamic Blob Resource",
new ResourceTemplate(blobUriTemplate, {
list: undefined,
complete: { resourceId: resourceIdForResourceTemplateCompleter },
}),
{
mimeType: "application/octet-stream",
description:
"Binary (base64) dynamic resource fabricated from the {resourceId} variable, which must be an integer.",
},
async (uri, variables) => {
const resourceId = parseResourceId(uri, variables);
return {
contents: [blobResource(uri, resourceId)],
};
}
);
};

View File

@@ -0,0 +1,118 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
InMemoryTaskStore,
InMemoryTaskMessageQueue,
} from "@modelcontextprotocol/sdk/experimental/tasks";
import {
setSubscriptionHandlers,
stopSimulatedResourceUpdates,
} from "../resources/subscriptions.js";
import { registerConditionalTools, registerTools } from "../tools/index.js";
import { registerResources, readInstructions } from "../resources/index.js";
import { registerPrompts } from "../prompts/index.js";
import { stopSimulatedLogging } from "./logging.js";
import { syncRoots } from "./roots.js";
// Server Factory response
export type ServerFactoryResponse = {
server: McpServer;
cleanup: (sessionId?: string) => void;
};
/**
* Server Factory
*
* This function initializes a `McpServer` with specific capabilities and instructions,
* registers tools, resources, and prompts, and configures resource subscription handlers.
*
* @returns {ServerFactoryResponse} An object containing the server instance, and a `cleanup`
* function for handling server-side cleanup when a session ends.
*
* Properties of the returned object:
* - `server` {Object}: The initialized server instance.
* - `cleanup` {Function}: Function to perform cleanup operations for a closing session.
*/
export const createServer: () => ServerFactoryResponse = () => {
// Read the server instructions
const instructions = readInstructions();
// Create task store and message queue for task support
const taskStore = new InMemoryTaskStore();
const taskMessageQueue = new InMemoryTaskMessageQueue();
let initializeTimeout: NodeJS.Timeout | null = null;
// Create the server
const server = new McpServer(
{
name: "mcp-servers/everything",
title: "Everything Reference Server",
version: "2.0.0",
},
{
capabilities: {
tools: {
listChanged: true,
},
prompts: {
listChanged: true,
},
resources: {
subscribe: true,
listChanged: true,
},
logging: {},
tasks: {
list: {},
cancel: {},
requests: {
tools: {
call: {},
},
},
},
},
instructions,
taskStore,
taskMessageQueue,
}
);
// Register the tools
registerTools(server);
// Register the resources
registerResources(server);
// Register the prompts
registerPrompts(server);
// Set resource subscription handlers
setSubscriptionHandlers(server);
// Perform post-initialization operations
server.server.oninitialized = async () => {
// Register conditional tools now that client capabilities are known.
// This finishes before the `notifications/initialized` handler finishes.
registerConditionalTools(server);
// Sync roots if the client supports them.
// This is delayed until after the `notifications/initialized` handler finishes,
// otherwise, the request gets lost.
const sessionId = server.server.transport?.sessionId;
initializeTimeout = setTimeout(() => syncRoots(server, sessionId), 350);
};
// Return the ServerFactoryResponse
return {
server,
cleanup: (sessionId?: string) => {
// Stop any simulated logging or resource updates that may have been initiated.
stopSimulatedLogging(sessionId);
stopSimulatedResourceUpdates(sessionId);
// Clean up task store timers
taskStore.cleanup();
if (initializeTimeout) clearTimeout(initializeTimeout);
},
} satisfies ServerFactoryResponse;
};

View File

@@ -0,0 +1,82 @@
import { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Map session ID to the interval for sending logging messages to the client
const logsUpdateIntervals: Map<string | undefined, NodeJS.Timeout | undefined> =
new Map<string | undefined, NodeJS.Timeout | undefined>();
/**
* Initiates a simulated logging process by sending random log messages to the client at a
* fixed interval. Each log message contains a random logging level and optional session ID.
*
* @param {McpServer} server - The server instance responsible for handling the logging messages.
* @param {string | undefined} sessionId - An optional identifier for the session. If provided,
* the session ID will be appended to log messages.
*/
export const beginSimulatedLogging = (
server: McpServer,
sessionId: string | undefined
) => {
const maybeAppendSessionId = sessionId ? ` - SessionId ${sessionId}` : "";
const messages: { level: LoggingLevel; data: string }[] = [
{ level: "debug", data: `Debug-level message${maybeAppendSessionId}` },
{ level: "info", data: `Info-level message${maybeAppendSessionId}` },
{ level: "notice", data: `Notice-level message${maybeAppendSessionId}` },
{
level: "warning",
data: `Warning-level message${maybeAppendSessionId}`,
},
{ level: "error", data: `Error-level message${maybeAppendSessionId}` },
{
level: "critical",
data: `Critical-level message${maybeAppendSessionId}`,
},
{ level: "alert", data: `Alert level-message${maybeAppendSessionId}` },
{
level: "emergency",
data: `Emergency-level message${maybeAppendSessionId}`,
},
];
/**
* Send a simulated logging message to the client
*/
const sendSimulatedLoggingMessage = async (sessionId: string | undefined) => {
// By using the `sendLoggingMessage` function to send the message, we
// ensure that the client's chosen logging level will be respected
await server.sendLoggingMessage(
messages[Math.floor(Math.random() * messages.length)],
sessionId
);
};
// Set the interval to send later logging messages to this client
if (!logsUpdateIntervals.has(sessionId)) {
// Send once immediately
sendSimulatedLoggingMessage(sessionId);
// Send a randomly-leveled log message every 5 seconds
logsUpdateIntervals.set(
sessionId,
setInterval(() => sendSimulatedLoggingMessage(sessionId), 5000)
);
}
};
/**
* Stops the simulated logging process for a given session.
*
* This function halts the periodic logging updates associated with the specified
* session ID by clearing the interval and removing the session's tracking
* reference. Session ID can be undefined for stdio.
*
* @param {string} [sessionId] - The optional unique identifier of the session.
*/
export const stopSimulatedLogging = (sessionId?: string) => {
// Remove active intervals
if (logsUpdateIntervals.has(sessionId)) {
const logsUpdateInterval = logsUpdateIntervals.get(sessionId);
clearInterval(logsUpdateInterval);
logsUpdateIntervals.delete(sessionId);
}
};

View File

@@ -0,0 +1,90 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
Root,
RootsListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js";
// Track roots by session id
export const roots: Map<string | undefined, Root[]> = new Map<
string | undefined,
Root[]
>();
/**
* Get the latest the client roots list for the session.
*
* - Request and cache the roots list for the session if it has not been fetched before.
* - Return the cached roots list for the session if it exists.
*
* When requesting the roots list for a session, it also sets up a `roots/list_changed`
* notification handler. This ensures that updates are automatically fetched and handled
* in real-time.
*
* This function is idempotent. It should only request roots from the client once per session,
* returning the cached version thereafter.
*
* @param {McpServer} server - An instance of the MCP server used to communicate with the client.
* @param {string} [sessionId] - An optional session id used to associate the roots list with a specific client session.
*
* @throws {Error} In case of a failure to request the roots from the client, an error log message is sent.
*/
export const syncRoots = async (server: McpServer, sessionId?: string) => {
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientSupportsRoots: boolean = clientCapabilities?.roots !== undefined;
// Fetch the roots list for this client
if (clientSupportsRoots) {
// Function to request the updated roots list from the client
const requestRoots = async () => {
try {
// Request the updated roots list from the client
const response = await server.server.listRoots();
if (response && "roots" in response) {
// Store the roots list for this client
roots.set(sessionId, response.roots);
// Notify the client of roots received
await server.sendLoggingMessage(
{
level: "info",
logger: "everything-server",
data: `Roots updated: ${response?.roots?.length} root(s) received from client`,
},
sessionId
);
} else {
await server.sendLoggingMessage(
{
level: "info",
logger: "everything-server",
data: "Client returned no roots set",
},
sessionId
);
}
} catch (error) {
console.error(
`Failed to request roots from client ${sessionId}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
};
// If the roots have not been synced for this client,
// set notification handler and request initial roots
if (!roots.has(sessionId)) {
// Set the list changed notification handler
server.server.setNotificationHandler(
RootsListChangedNotificationSchema,
requestRoots
);
// Request the initial roots list immediately
await requestRoots();
}
// Return the roots list for this client
return roots.get(sessionId);
}
};

View File

@@ -0,0 +1,34 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Tool input schema
export const EchoSchema = z.object({
message: z.string().describe("Message to echo"),
});
// Tool configuration
const name = "echo";
const config = {
title: "Echo Tool",
description: "Echoes back the input string",
inputSchema: EchoSchema,
};
/**
* Registers the 'echo' tool.
*
* The registered tool validates input arguments using the EchoSchema and
* returns a response that echoes the message provided in the arguments.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
* @returns {void}
*/
export const registerEchoTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
const validatedArgs = EchoSchema.parse(args);
return {
content: [{ type: "text", text: `Echo: ${validatedArgs.message}` }],
};
});
};

View File

@@ -0,0 +1,89 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { MCP_TINY_IMAGE } from "./get-tiny-image.js";
// Tool input schema
const GetAnnotatedMessageSchema = z.object({
messageType: z
.enum(["error", "success", "debug"])
.describe("Type of message to demonstrate different annotation patterns"),
includeImage: z
.boolean()
.default(false)
.describe("Whether to include an example image"),
});
// Tool configuration
const name = "get-annotated-message";
const config = {
title: "Get Annotated Message Tool",
description:
"Demonstrates how annotations can be used to provide metadata about content.",
inputSchema: GetAnnotatedMessageSchema,
};
/**
* Registers the 'get-annotated-message' tool.
*
* The registered tool generates and sends messages with specific types, such as error,
* success, or debug, carrying associated annotations like priority level and intended
* audience.
*
* The response will have annotations and optionally contain an annotated image.
*
* @function
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerGetAnnotatedMessageTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
const { messageType, includeImage } = GetAnnotatedMessageSchema.parse(args);
const content: CallToolResult["content"] = [];
// Main message with different priorities/audiences based on type
if (messageType === "error") {
content.push({
type: "text",
text: "Error: Operation failed",
annotations: {
priority: 1.0, // Errors are highest priority
audience: ["user", "assistant"], // Both need to know about errors
},
});
} else if (messageType === "success") {
content.push({
type: "text",
text: "Operation completed successfully",
annotations: {
priority: 0.7, // Success messages are important but not critical
audience: ["user"], // Success mainly for user consumption
},
});
} else if (messageType === "debug") {
content.push({
type: "text",
text: "Debug: Cache hit ratio 0.95, latency 150ms",
annotations: {
priority: 0.3, // Debug info is low priority
audience: ["assistant"], // Technical details for assistant
},
});
}
// Optional image with its own annotations
if (includeImage) {
content.push({
type: "image",
data: MCP_TINY_IMAGE,
mimeType: "image/png",
annotations: {
priority: 0.5,
audience: ["user"], // Images primarily for user visualization
},
});
}
return { content };
});
};

View File

@@ -0,0 +1,33 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
// Tool configuration
const name = "get-env";
const config = {
title: "Print Environment Tool",
description:
"Returns all environment variables, helpful for debugging MCP server configuration",
inputSchema: {},
};
/**
* Registers the 'get-env' tool.
*
* The registered tool Retrieves and returns the environment variables
* of the current process as a JSON-formatted string encapsulated in a text response.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
* @returns {void}
*/
export const registerGetEnvTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
return {
content: [
{
type: "text",
text: JSON.stringify(process.env, null, 2),
},
],
};
});
};

View File

@@ -0,0 +1,80 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
textResource,
textResourceUri,
blobResourceUri,
blobResource,
} from "../resources/templates.js";
// Tool input schema
const GetResourceLinksSchema = z.object({
count: z
.number()
.min(1)
.max(10)
.default(3)
.describe("Number of resource links to return (1-10)"),
});
// Tool configuration
const name = "get-resource-links";
const config = {
title: "Get Resource Links Tool",
description:
"Returns up to ten resource links that reference different types of resources",
inputSchema: GetResourceLinksSchema,
};
/**
* Registers the 'get-resource-reference' tool.
*
* The registered tool retrieves a specified number of resource links and their metadata.
* Resource links are dynamically generated as either text or binary blob resources,
* based on their ID being even or odd.
* The response contains a "text" introductory block and multiple "resource_link" blocks.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerGetResourceLinksTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
const { count } = GetResourceLinksSchema.parse(args);
// Add intro text content block
const content: CallToolResult["content"] = [];
content.push({
type: "text",
text: `Here are ${count} resource links to resources available in this server:`,
});
// Create resource link content blocks
for (let resourceId = 1; resourceId <= count; resourceId++) {
// Get resource uri for text or blob resource based on odd/even resourceId
const isOdd = resourceId % 2 === 0;
const uri = isOdd
? textResourceUri(resourceId)
: blobResourceUri(resourceId);
// Get resource based on the resource type
const resource = isOdd
? textResource(uri, resourceId)
: blobResource(uri, resourceId);
content.push({
type: "resource_link",
uri: resource.uri,
name: `${isOdd ? "Text" : "Blob"} Resource ${resourceId}`,
description: `Resource ${resourceId}: ${
resource.mimeType === "text/plain"
? "plaintext resource"
: "binary blob resource"
}`,
mimeType: resource.mimeType,
});
}
return { content };
});
};

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
textResource,
textResourceUri,
blobResourceUri,
blobResource,
RESOURCE_TYPE_BLOB,
RESOURCE_TYPE_TEXT,
RESOURCE_TYPES,
} from "../resources/templates.js";
// Tool input schema
const GetResourceReferenceSchema = z.object({
resourceType: z
.enum([RESOURCE_TYPE_TEXT, RESOURCE_TYPE_BLOB])
.default(RESOURCE_TYPE_TEXT),
resourceId: z
.number()
.default(1)
.describe("ID of the text resource to fetch"),
});
// Tool configuration
const name = "get-resource-reference";
const config = {
title: "Get Resource Reference Tool",
description: "Returns a resource reference that can be used by MCP clients",
inputSchema: GetResourceReferenceSchema,
};
/**
* Registers the 'get-resource-reference' tool.
*
* The registered tool validates and processes arguments for retrieving a resource
* reference. Supported resource types include predefined `RESOURCE_TYPE_TEXT` and
* `RESOURCE_TYPE_BLOB`. The retrieved resource's reference will include the resource
* ID, type, and its associated URI.
*
* The tool performs the following operations:
* 1. Validates the `resourceType` argument to ensure it matches a supported type.
* 2. Validates the `resourceId` argument to ensure it is a finite positive integer.
* 3. Constructs a URI for the resource based on its type (text or blob).
* 4. Retrieves the resource and returns it in a content block.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerGetResourceReferenceTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
// Validate resource type argument
const { resourceType } = args;
if (!RESOURCE_TYPES.includes(resourceType)) {
throw new Error(
`Invalid resourceType: ${args?.resourceType}. Must be ${RESOURCE_TYPE_TEXT} or ${RESOURCE_TYPE_BLOB}.`
);
}
// Validate resourceId argument
const resourceId = Number(args?.resourceId);
if (
!Number.isFinite(resourceId) ||
!Number.isInteger(resourceId) ||
resourceId < 1
) {
throw new Error(
`Invalid resourceId: ${args?.resourceId}. Must be a finite positive integer.`
);
}
// Get resource based on the resource type
const uri =
resourceType === RESOURCE_TYPE_TEXT
? textResourceUri(resourceId)
: blobResourceUri(resourceId);
const resource =
resourceType === RESOURCE_TYPE_TEXT
? textResource(uri, resourceId)
: blobResource(uri, resourceId);
return {
content: [
{
type: "text",
text: `Returning resource reference for Resource ${resourceId}:`,
},
{
type: "resource",
resource: resource,
},
{
type: "text",
text: `You can access this resource using the URI: ${resource.uri}`,
},
],
};
});
};

View File

@@ -0,0 +1,92 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { syncRoots } from "../server/roots.js";
// Tool configuration
const name = "get-roots-list";
const config = {
title: "Get Roots List Tool",
description:
"Lists the current MCP roots provided by the client. Demonstrates the roots protocol capability even though this server doesn't access files.",
inputSchema: {},
};
/**
* Registers the 'get-roots-list' tool.
*
* If the client does not support the roots capability, the tool is not registered.
*
* The registered tool interacts with the MCP roots capability, which enables the server to access
* information about the client's workspace directories or file system roots.
*
* When supported, the server automatically retrieves and formats the current list of roots from the
* client upon connection and whenever the client sends a `roots/list_changed` notification.
*
* Therefore, this tool displays the roots that the server currently knows about for the connected
* client. If for some reason the server never got the initial roots list, the tool will request the
* list from the client again.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerGetRootsListTool = (server: McpServer) => {
// Does client support roots?
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientSupportsRoots: boolean = clientCapabilities.roots !== undefined;
// If so, register tool
if (clientSupportsRoots) {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
// Get the current rootsFetch the current roots list from the client if need be
const currentRoots = await syncRoots(server, extra.sessionId);
// Respond if client supports roots but doesn't have any configured
if (
clientSupportsRoots &&
(!currentRoots || currentRoots.length === 0)
) {
return {
content: [
{
type: "text",
text:
"The client supports roots but no roots are currently configured.\n\n" +
"This could mean:\n" +
"1. The client hasn't provided any roots yet\n" +
"2. The client provided an empty roots list\n" +
"3. The roots configuration is still being loaded",
},
],
};
}
// Create formatted response if there is a list of roots
const rootsList = currentRoots
? currentRoots
.map((root, index) => {
return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${
root.uri
}`;
})
.join("\n\n")
: "No roots found";
return {
content: [
{
type: "text",
text:
`Current MCP Roots (${
currentRoots!.length
} total):\n\n${rootsList}\n\n` +
"Note: This server demonstrates the roots protocol capability but doesn't actually access files. " +
"The roots are provided by the MCP client and can be used by servers that need file system access.",
},
],
};
}
);
}
};

View File

@@ -0,0 +1,86 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolResult,
ContentBlock,
} from "@modelcontextprotocol/sdk/types.js";
// Tool input schema
const GetStructuredContentInputSchema = {
location: z
.enum(["New York", "Chicago", "Los Angeles"])
.describe("Choose city"),
};
// Tool output schema
const GetStructuredContentOutputSchema = z.object({
temperature: z.number().describe("Temperature in celsius"),
conditions: z.string().describe("Weather conditions description"),
humidity: z.number().describe("Humidity percentage"),
});
// Tool configuration
const name = "get-structured-content";
const config = {
title: "Get Structured Content Tool",
description:
"Returns structured content along with an output schema for client data validation",
inputSchema: GetStructuredContentInputSchema,
outputSchema: GetStructuredContentOutputSchema,
};
/**
* Registers the 'get-structured-content' tool.
*
* The registered tool processes incoming arguments using a predefined input schema,
* generates structured content with weather information including temperature,
* conditions, and humidity, and returns both backward-compatible content blocks
* and structured content in the response.
*
* The response contains:
* - `content`: An array of content blocks, presented as JSON stringified objects.
* - `structuredContent`: A JSON structured representation of the weather data.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerGetStructuredContentTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
// Get simulated weather for the chosen city
let weather;
switch (args.location) {
case "New York":
weather = {
temperature: 33,
conditions: "Cloudy",
humidity: 82,
};
break;
case "Chicago":
weather = {
temperature: 36,
conditions: "Light rain / drizzle",
humidity: 82,
};
break;
case "Los Angeles":
weather = {
temperature: 73,
conditions: "Sunny / Clear",
humidity: 48,
};
break;
}
const backwardCompatibleContentBlock: ContentBlock = {
type: "text",
text: JSON.stringify(weather),
};
return {
content: [backwardCompatibleContentBlock],
structuredContent: weather,
};
});
};

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
// Tool input schema
const GetSumSchema = z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
});
// Tool configuration
const name = "get-sum";
const config = {
title: "Get Sum Tool",
description: "Returns the sum of two numbers",
inputSchema: GetSumSchema,
};
/**
* Registers the 'get-sum' tool.
**
* The registered tool processes input arguments, validates them using a predefined schema,
* calculates the sum of two numeric values, and returns the result in a content block.
*
* Expects input arguments to conform to a specific schema that includes two numeric properties, `a` and `b`.
* Validation is performed to ensure the input adheres to the expected structure before calculating the sum.
*
* The result is returned as a Promise resolving to an object containing the computed sum in a text format.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerGetSumTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
const validatedArgs = GetSumSchema.parse(args);
const sum = validatedArgs.a + validatedArgs.b;
return {
content: [
{
type: "text",
text: `The sum of ${validatedArgs.a} and ${validatedArgs.b} is ${sum}.`,
},
],
};
});
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,243 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult, Resource } from "@modelcontextprotocol/sdk/types.js";
import { gzipSync } from "node:zlib";
import {
getSessionResourceURI,
registerSessionResource,
} from "../resources/session.js";
// Maximum input file size - 10 MB default
const GZIP_MAX_FETCH_SIZE = Number(
process.env.GZIP_MAX_FETCH_SIZE ?? String(10 * 1024 * 1024)
);
// Maximum fetch time - 30 seconds default.
const GZIP_MAX_FETCH_TIME_MILLIS = Number(
process.env.GZIP_MAX_FETCH_TIME_MILLIS ?? String(30 * 1000)
);
// Comma-separated list of allowed domains. Empty means all domains are allowed.
const GZIP_ALLOWED_DOMAINS = (process.env.GZIP_ALLOWED_DOMAINS ?? "")
.split(",")
.map((d) => d.trim().toLowerCase())
.filter((d) => d.length > 0);
// Tool input schema
const GZipFileAsResourceSchema = z.object({
name: z.string().describe("Name of the output file").default("README.md.gz"),
data: z
.string()
.url()
.describe("URL or data URI of the file content to compress")
.default(
"https://raw.githubusercontent.com/modelcontextprotocol/servers/refs/heads/main/README.md"
),
outputType: z
.enum(["resourceLink", "resource"])
.default("resourceLink")
.describe(
"How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object."
),
});
// Tool configuration
const name = "gzip-file-as-resource";
const config = {
title: "GZip File as Resource Tool",
description:
"Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session.",
inputSchema: GZipFileAsResourceSchema,
};
/**
* Registers the `gzip-file-as-resource` tool.
*
* The registered tool compresses input data using gzip, and makes the resulting file accessible
* as a resource for the duration of the session.
*
* The tool supports two output types:
* - "resource": Returns the resource directly, including its URI, MIME type, and base64-encoded content.
* - "resourceLink": Returns a link to access the resource later.
*
* If an unrecognized `outputType` is provided, the tool throws an error.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
* @throws {Error} Throws an error if an unknown output type is specified.
*/
export const registerGZipFileAsResourceTool = (server: McpServer) => {
server.registerTool(name, config, async (args): Promise<CallToolResult> => {
const {
name,
data: dataUri,
outputType,
} = GZipFileAsResourceSchema.parse(args);
// Validate data uri
const url = validateDataURI(dataUri);
// Fetch the data
const response = await fetchSafely(url, {
maxBytes: GZIP_MAX_FETCH_SIZE,
timeoutMillis: GZIP_MAX_FETCH_TIME_MILLIS,
});
// Compress the data using gzip
const inputBuffer = Buffer.from(response);
const compressedBuffer = gzipSync(inputBuffer);
// Create resource
const uri = getSessionResourceURI(name);
const blob = compressedBuffer.toString("base64");
const mimeType = "application/gzip";
const resource = <Resource>{ uri, name, mimeType };
// Register resource, get resource link in return
const resourceLink = registerSessionResource(
server,
resource,
"blob",
blob
);
// Return the resource or a resource link that can be used to access this resource later
if (outputType === "resource") {
return {
content: [
{
type: "resource",
resource: { uri, mimeType, blob },
},
],
};
} else if (outputType === "resourceLink") {
return {
content: [resourceLink],
};
} else {
throw new Error(`Unknown outputType: ${outputType}`);
}
});
};
/**
* Validates a given data URI to ensure it follows the appropriate protocols and rules.
*
* @param {string} dataUri - The data URI to validate. Must be an HTTP, HTTPS, or data protocol URL. If a domain is provided, it must match the allowed domains list if applicable.
* @return {URL} The validated and parsed URL object.
* @throws {Error} If the data URI does not use a supported protocol or does not meet allowed domains criteria.
*/
function validateDataURI(dataUri: string): URL {
// Validate Inputs
const url = new URL(dataUri);
try {
if (
url.protocol !== "http:" &&
url.protocol !== "https:" &&
url.protocol !== "data:"
) {
throw new Error(
`Unsupported URL protocol for ${dataUri}. Only http, https, and data URLs are supported.`
);
}
if (
GZIP_ALLOWED_DOMAINS.length > 0 &&
(url.protocol === "http:" || url.protocol === "https:")
) {
const domain = url.hostname;
const domainAllowed = GZIP_ALLOWED_DOMAINS.some((allowedDomain) => {
return domain === allowedDomain || domain.endsWith(`.${allowedDomain}`);
});
if (!domainAllowed) {
throw new Error(`Domain ${domain} is not in the allowed domains list.`);
}
}
} catch (error) {
throw new Error(
`Error processing file ${dataUri}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
return url;
}
/**
* Fetches data safely from a given URL while ensuring constraints on maximum byte size and timeout duration.
*
* @param {URL} url The URL to fetch data from.
* @param {Object} options An object containing options for the fetch operation.
* @param {number} options.maxBytes The maximum allowed size (in bytes) of the response. If the response exceeds this size, the operation will be aborted.
* @param {number} options.timeoutMillis The timeout duration (in milliseconds) for the fetch operation. If the fetch takes longer, it will be aborted.
* @return {Promise<ArrayBuffer>} A promise that resolves with the response as an ArrayBuffer if successful.
* @throws {Error} Throws an error if the response size exceeds the defined limit, the fetch times out, or the response is otherwise invalid.
*/
async function fetchSafely(
url: URL,
{ maxBytes, timeoutMillis }: { maxBytes: number; timeoutMillis: number }
): Promise<ArrayBuffer> {
const controller = new AbortController();
const timeout = setTimeout(
() =>
controller.abort(
`Fetching ${url} took more than ${timeoutMillis} ms and was aborted.`
),
timeoutMillis
);
try {
// Fetch the data
const response = await fetch(url, { signal: controller.signal });
if (!response.body) {
throw new Error("No response body");
}
// Note: we can't trust the Content-Length header: a malicious or clumsy server could return much more data than advertised.
// We check it here for early bail-out, but we still need to monitor actual bytes read below.
const contentLengthHeader = response.headers.get("content-length");
if (contentLengthHeader != null) {
const contentLength = parseInt(contentLengthHeader, 10);
if (contentLength > maxBytes) {
throw new Error(
`Content-Length for ${url} exceeds max of ${maxBytes}: ${contentLength}`
);
}
}
// Read the fetched data from the response body
const reader = response.body.getReader();
const chunks = [];
let totalSize = 0;
// Read chunks until done
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.length;
if (totalSize > maxBytes) {
reader.cancel();
throw new Error(`Response from ${url} exceeds ${maxBytes} bytes`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
// Combine chunks into a single buffer
const buffer = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.length;
}
return buffer.buffer;
} finally {
clearTimeout(timeout);
}
}

View File

@@ -0,0 +1,53 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerGetAnnotatedMessageTool } from "./get-annotated-message.js";
import { registerEchoTool } from "./echo.js";
import { registerGetEnvTool } from "./get-env.js";
import { registerGetResourceLinksTool } from "./get-resource-links.js";
import { registerGetResourceReferenceTool } from "./get-resource-reference.js";
import { registerGetRootsListTool } from "./get-roots-list.js";
import { registerGetStructuredContentTool } from "./get-structured-content.js";
import { registerGetSumTool } from "./get-sum.js";
import { registerGetTinyImageTool } from "./get-tiny-image.js";
import { registerGZipFileAsResourceTool } from "./gzip-file-as-resource.js";
import { registerToggleSimulatedLoggingTool } from "./toggle-simulated-logging.js";
import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js";
import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js";
import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js";
import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js";
import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js";
import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js";
import { registerSimulateResearchQueryTool } from "./simulate-research-query.js";
/**
* Register the tools with the MCP server.
* @param server
*/
export const registerTools = (server: McpServer) => {
registerEchoTool(server);
registerGetAnnotatedMessageTool(server);
registerGetEnvTool(server);
registerGetResourceLinksTool(server);
registerGetResourceReferenceTool(server);
registerGetStructuredContentTool(server);
registerGetSumTool(server);
registerGetTinyImageTool(server);
registerGZipFileAsResourceTool(server);
registerToggleSimulatedLoggingTool(server);
registerToggleSubscriberUpdatesTool(server);
registerTriggerLongRunningOperationTool(server);
};
/**
* Register the tools that are conditional upon client capabilities.
* These must be registered conditionally, after initialization.
*/
export const registerConditionalTools = (server: McpServer) => {
registerGetRootsListTool(server);
registerTriggerElicitationRequestTool(server);
registerTriggerSamplingRequestTool(server);
// Task-based research tool (uses experimental tasks API)
registerSimulateResearchQueryTool(server);
// Bidirectional task tools - server sends requests that client executes as tasks
registerTriggerSamplingRequestAsyncTool(server);
registerTriggerElicitationRequestAsyncTool(server);
};

View File

@@ -0,0 +1,345 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolResult,
GetTaskResult,
Task,
ElicitResult,
ElicitResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { CreateTaskResult } from "@modelcontextprotocol/sdk/experimental/tasks";
// Tool input schema
const SimulateResearchQuerySchema = z.object({
topic: z.string().describe("The research topic to investigate"),
ambiguous: z
.boolean()
.default(false)
.describe(
"Simulate an ambiguous query that requires clarification (triggers input_required status)"
),
});
// Research stages
const STAGES = [
"Gathering sources",
"Analyzing content",
"Synthesizing findings",
"Generating report",
];
// Duration per stage in milliseconds
const STAGE_DURATION = 1000;
// Internal state for tracking research tasks
interface ResearchState {
topic: string;
ambiguous: boolean;
currentStage: number;
clarification?: string;
completed: boolean;
result?: CallToolResult;
}
// Map to store research state per task
const researchStates = new Map<string, ResearchState>();
/**
* Runs the background research process.
* Updates task status as it progresses through stages.
* If clarification is needed, attempts elicitation via sendRequest.
*
* Note: Elicitation only works on STDIO transport. On HTTP transport,
* sendRequest will fail and the task will use a default interpretation.
* Full HTTP support requires SDK PR #1210's elicitInputStream API.
*/
async function runResearchProcess(
taskId: string,
args: z.infer<typeof SimulateResearchQuerySchema>,
taskStore: {
updateTaskStatus: (
taskId: string,
status: Task["status"],
message?: string
) => Promise<void>;
storeTaskResult: (
taskId: string,
status: "completed" | "failed",
result: CallToolResult
) => Promise<void>;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendRequest: any
): Promise<void> {
const state = researchStates.get(taskId);
if (!state) return;
// Process each stage
for (let i = state.currentStage; i < STAGES.length; i++) {
state.currentStage = i;
// Check if task was cancelled externally
if (state.completed) return;
// Update status message for current stage
await taskStore.updateTaskStatus(taskId, "working", `${STAGES[i]}...`);
// At synthesis stage (index 2), check if clarification is needed
if (i === 2 && state.ambiguous && !state.clarification) {
// Update status to show we're requesting input (spec SHOULD)
await taskStore.updateTaskStatus(
taskId,
"input_required",
`Found multiple interpretations for "${state.topic}". Requesting clarification...`
);
try {
// Try elicitation via sendRequest (works on STDIO, fails on HTTP)
const elicitResult: ElicitResult = await sendRequest(
{
method: "elicitation/create",
params: {
message: `The research query "${state.topic}" could have multiple interpretations. Please clarify what you're looking for:`,
requestedSchema: {
type: "object",
properties: {
interpretation: {
type: "string",
title: "Clarification",
description:
"Which interpretation of the topic do you mean?",
oneOf: getInterpretationsForTopic(state.topic),
},
},
required: ["interpretation"],
},
},
},
ElicitResultSchema
);
// Process elicitation response
if (elicitResult.action === "accept" && elicitResult.content) {
state.clarification =
(elicitResult.content as { interpretation?: string })
.interpretation || "User accepted without selection";
} else if (elicitResult.action === "decline") {
state.clarification = "User declined - using default interpretation";
} else {
state.clarification = "User cancelled - using default interpretation";
}
} catch (error) {
// Elicitation failed (likely HTTP transport without streaming support)
// Use default interpretation and continue - task should still complete
console.warn(
`Elicitation failed for task ${taskId} (HTTP transport?):`,
error instanceof Error ? error.message : String(error)
);
state.clarification =
"technical (default - elicitation unavailable on HTTP)";
}
// Resume with working status (spec SHOULD)
await taskStore.updateTaskStatus(
taskId,
"working",
`Continuing with interpretation: "${state.clarification}"...`
);
// Continue processing (no return - just keep going through the loop)
}
// Simulate work for this stage
await new Promise((resolve) => setTimeout(resolve, STAGE_DURATION));
}
// All stages complete - generate result
state.completed = true;
const result = generateResearchReport(state);
state.result = result;
await taskStore.storeTaskResult(taskId, "completed", result);
}
/**
* Generates the final research report with educational content about tasks.
*/
function generateResearchReport(state: ResearchState): CallToolResult {
const topic = state.clarification
? `${state.topic} (${state.clarification})`
: state.topic;
const report = `# Research Report: ${topic}
## Research Parameters
- **Topic**: ${state.topic}
${state.clarification ? `- **Clarification**: ${state.clarification}` : ""}
## Synthesis
This research query was processed through ${STAGES.length} stages:
${STAGES.map((s, i) => `- Stage ${i + 1}: ${s}`).join("\n")}
---
## About This Demo (SEP-1686: Tasks)
This tool demonstrates MCP's task-based execution pattern for long-running operations:
**Task Lifecycle Demonstrated:**
1. \`tools/call\` with \`task\` parameter → Server returns \`CreateTaskResult\` (not the final result)
2. Client polls \`tasks/get\` → Server returns current status and \`statusMessage\`
3. Status progressed: \`working\`${
state.clarification ? `\`input_required\`\`working\`` : ""
}\`completed\`
4. Client calls \`tasks/result\` → Server returns this final result
${
state.clarification
? `**Elicitation Flow:**
When the query was ambiguous, the server sent an \`elicitation/create\` request
to the client. The task status changed to \`input_required\` while awaiting user input.
${
state.clarification.includes("unavailable on HTTP")
? `
**Note:** Elicitation was skipped because this server is running over HTTP transport.
The current SDK's \`sendRequest\` only works over STDIO. Full HTTP elicitation support
requires SDK PR #1210's streaming \`elicitInputStream\` API.
`
: `After receiving clarification ("${state.clarification}"), the task resumed processing and completed.`
}
`
: ""
}
**Key Concepts:**
- Tasks enable "call now, fetch later" patterns
- \`statusMessage\` provides human-readable progress updates
- Tasks have TTL (time-to-live) for automatic cleanup
- \`pollInterval\` suggests how often to check status
- Elicitation requests can be sent directly during task execution
*This is a simulated research report from the Everything MCP Server.*
`;
return {
content: [
{
type: "text",
text: report,
},
],
};
}
/**
* Registers the 'simulate-research-query' tool as a task-based tool.
*
* This tool demonstrates the MCP Tasks feature (SEP-1686) with a real-world scenario:
* a research tool that gathers and synthesizes information from multiple sources.
* If the query is ambiguous, it pauses to ask for clarification before completing.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerSimulateResearchQueryTool = (server: McpServer) => {
// Check if client supports elicitation (needed for input_required flow)
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientSupportsElicitation: boolean =
clientCapabilities.elicitation !== undefined;
server.experimental.tasks.registerToolTask(
"simulate-research-query",
{
title: "Simulate Research Query",
description:
"Simulates a deep research operation that gathers, analyzes, and synthesizes information. " +
"Demonstrates MCP task-based operations with progress through multiple stages. " +
"If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification.",
inputSchema: SimulateResearchQuerySchema,
execution: { taskSupport: "required" },
},
{
/**
* Creates a new research task and starts background processing.
*/
createTask: async (args, extra): Promise<CreateTaskResult> => {
const validatedArgs = SimulateResearchQuerySchema.parse(args);
// Create the task in the store
const task = await extra.taskStore.createTask({
ttl: 300000, // 5 minutes
pollInterval: 1000,
});
// Initialize research state
const state: ResearchState = {
topic: validatedArgs.topic,
ambiguous: validatedArgs.ambiguous && clientSupportsElicitation,
currentStage: 0,
completed: false,
};
researchStates.set(task.taskId, state);
// Start background research (don't await - runs asynchronously)
// Pass sendRequest for elicitation (works on STDIO, gracefully degrades on HTTP)
runResearchProcess(
task.taskId,
validatedArgs,
extra.taskStore,
extra.sendRequest
).catch((error) => {
console.error(`Research task ${task.taskId} failed:`, error);
extra.taskStore
.updateTaskStatus(task.taskId, "failed", String(error))
.catch(console.error);
});
return { task };
},
/**
* Returns the current status of the research task.
*/
getTask: async (args, extra): Promise<GetTaskResult> => {
return await extra.taskStore.getTask(extra.taskId);
},
/**
* Returns the task result.
* Elicitation is now handled directly in the background process.
*/
getTaskResult: async (args, extra): Promise<CallToolResult> => {
// Return the stored result
const result = await extra.taskStore.getTaskResult(extra.taskId);
// Clean up state
researchStates.delete(extra.taskId);
return result as CallToolResult;
},
}
);
};
/**
* Returns contextual interpretation options based on the topic.
*/
function getInterpretationsForTopic(
topic: string
): Array<{ const: string; title: string }> {
const lowerTopic = topic.toLowerCase();
// Example: contextual interpretations for "python"
if (lowerTopic.includes("python")) {
return [
{ const: "programming", title: "Python programming language" },
{ const: "snake", title: "Python snake species" },
{ const: "comedy", title: "Monty Python comedy group" },
];
}
// Default generic interpretations
return [
{ const: "technical", title: "Technical/scientific perspective" },
{ const: "historical", title: "Historical perspective" },
{ const: "current", title: "Current events/news perspective" },
];
}

View File

@@ -0,0 +1,54 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
beginSimulatedLogging,
stopSimulatedLogging,
} from "../server/logging.js";
// Tool configuration
const name = "toggle-simulated-logging";
const config = {
title: "Toggle Simulated Logging",
description: "Toggles simulated, random-leveled logging on or off.",
inputSchema: {},
};
// Track enabled clients by session id
const clients: Set<string | undefined> = new Set<string | undefined>();
/**
* Registers the `toggle-simulated-logging` tool.
*
* The registered tool enables or disables the sending of periodic, random-leveled
* logging messages the connected client.
*
* When invoked, it either starts or stops simulated logging based on the session's
* current state. If logging for the specified session is active, it will be stopped;
* if it is inactive, logging will be started.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerToggleSimulatedLoggingTool = (server: McpServer) => {
server.registerTool(
name,
config,
async (_args, extra): Promise<CallToolResult> => {
const sessionId = extra?.sessionId;
let response: string;
if (clients.has(sessionId)) {
stopSimulatedLogging(sessionId);
clients.delete(sessionId);
response = `Stopped simulated logging for session ${sessionId}`;
} else {
beginSimulatedLogging(server, sessionId);
clients.add(sessionId);
response = `Started simulated, random-leveled logging for session ${sessionId} at a 5 second pace. Client's selected logging level will be respected. If an interval elapses and the message to be sent is below the selected level, it will not be sent. Thus at higher chosen logging levels, messages should arrive further apart. `;
}
return {
content: [{ type: "text", text: `${response}` }],
};
}
);
};

View File

@@ -0,0 +1,57 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
beginSimulatedResourceUpdates,
stopSimulatedResourceUpdates,
} from "../resources/subscriptions.js";
// Tool configuration
const name = "toggle-subscriber-updates";
const config = {
title: "Toggle Subscriber Updates",
description: "Toggles simulated resource subscription updates on or off.",
inputSchema: {},
};
// Track enabled clients by session id
const clients: Set<string | undefined> = new Set<string | undefined>();
/**
* Registers the `toggle-subscriber-updates` tool.
*
* The registered tool enables or disables the sending of periodic, simulated resource
* update messages the connected client for any subscriptions they have made.
*
* When invoked, it either starts or stops simulated resource updates based on the session's
* current state. If simulated updates for the specified session is active, it will be stopped;
* if it is inactive, simulated updates will be started.
*
* The response provides feedback indicating whether simulated updates were started or stopped,
* including the session ID.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerToggleSubscriberUpdatesTool = (server: McpServer) => {
server.registerTool(
name,
config,
async (_args, extra): Promise<CallToolResult> => {
const sessionId = extra?.sessionId;
let response: string;
if (clients.has(sessionId)) {
stopSimulatedResourceUpdates(sessionId);
clients.delete(sessionId);
response = `Stopped simulated resource updates for session ${sessionId}`;
} else {
beginSimulatedResourceUpdates(server, sessionId);
clients.add(sessionId);
response = `Started simulated resource updated notifications for session ${sessionId} at a 5 second pace. Client will receive updates for any resources the it is subscribed to.`;
}
return {
content: [{ type: "text", text: `${response}` }],
};
}
);
};

View File

@@ -0,0 +1,265 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Tool configuration
const name = "trigger-elicitation-request-async";
const config = {
title: "Trigger Async Elicitation Request Tool",
description:
"Trigger an async elicitation request that the CLIENT executes as a background task. " +
"Demonstrates bidirectional MCP tasks where the server sends an elicitation request and " +
"the client handles user input asynchronously, allowing the server to poll for completion.",
inputSchema: {},
};
// Poll interval in milliseconds
const POLL_INTERVAL = 1000;
// Maximum poll attempts before timeout (10 minutes for user input)
const MAX_POLL_ATTEMPTS = 600;
/**
* Registers the 'trigger-elicitation-request-async' tool.
*
* This tool demonstrates bidirectional MCP tasks for elicitation:
* - Server sends elicitation request to client with task metadata
* - Client creates a task and returns CreateTaskResult
* - Client prompts user for input (task status: input_required)
* - Server polls client's tasks/get endpoint for status
* - Server fetches final result from client's tasks/result endpoint
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerTriggerElicitationRequestAsyncTool = (
server: McpServer
) => {
// Check client capabilities
const clientCapabilities = server.server.getClientCapabilities() || {};
// Client must support elicitation AND tasks.requests.elicitation
const clientSupportsElicitation =
clientCapabilities.elicitation !== undefined;
const clientTasksCapability = clientCapabilities.tasks as
| {
requests?: { elicitation?: { create?: object } };
}
| undefined;
const clientSupportsAsyncElicitation =
clientTasksCapability?.requests?.elicitation?.create !== undefined;
if (clientSupportsElicitation && clientSupportsAsyncElicitation) {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
// Create the elicitation request WITH task metadata
// Using z.any() schema to avoid complex type matching with _meta
const request = {
method: "elicitation/create" as const,
params: {
task: {
ttl: 600000, // 10 minutes (user input may take a while)
},
message:
"Please provide inputs for the following fields (async task demo):",
requestedSchema: {
type: "object" as const,
properties: {
name: {
title: "Your Name",
type: "string" as const,
description: "Your full name",
},
favoriteColor: {
title: "Favorite Color",
type: "string" as const,
description: "What is your favorite color?",
enum: ["Red", "Blue", "Green", "Yellow", "Purple"],
},
agreeToTerms: {
title: "Terms Agreement",
type: "boolean" as const,
description: "Do you agree to the terms and conditions?",
},
},
required: ["name"],
},
},
};
// Send the elicitation request
// Client may return either:
// - ElicitResult (synchronous execution)
// - CreateTaskResult (task-based execution with { task } object)
const elicitResponse = await extra.sendRequest(
request as Parameters<typeof extra.sendRequest>[0],
z.union([
// CreateTaskResult - client created a task
z.object({
task: z.object({
taskId: z.string(),
status: z.string(),
pollInterval: z.number().optional(),
statusMessage: z.string().optional(),
}),
}),
// ElicitResult - synchronous execution
z.object({
action: z.string(),
content: z.any().optional(),
}),
])
);
// Check if client returned CreateTaskResult (has task object)
const isTaskResult = "task" in elicitResponse && elicitResponse.task;
if (!isTaskResult) {
// Client executed synchronously - return the direct response
return {
content: [
{
type: "text",
text: `[SYNC] Client executed synchronously:\n${JSON.stringify(
elicitResponse,
null,
2
)}`,
},
],
};
}
const taskId = elicitResponse.task.taskId;
const statusMessages: string[] = [];
statusMessages.push(`Task created: ${taskId}`);
// Poll for task completion
let attempts = 0;
let taskStatus = elicitResponse.task.status;
let taskStatusMessage: string | undefined;
while (
taskStatus !== "completed" &&
taskStatus !== "failed" &&
taskStatus !== "cancelled" &&
attempts < MAX_POLL_ATTEMPTS
) {
// Wait before polling
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
attempts++;
// Get task status from client
const pollResult = await extra.sendRequest(
{
method: "tasks/get",
params: { taskId },
},
z
.object({
status: z.string(),
statusMessage: z.string().optional(),
})
.passthrough()
);
taskStatus = pollResult.status;
taskStatusMessage = pollResult.statusMessage;
// Only log status changes or every 10 polls to avoid spam
if (
attempts === 1 ||
attempts % 10 === 0 ||
taskStatus !== "input_required"
) {
statusMessages.push(
`Poll ${attempts}: ${taskStatus}${
taskStatusMessage ? ` - ${taskStatusMessage}` : ""
}`
);
}
}
// Check for timeout
if (attempts >= MAX_POLL_ATTEMPTS) {
return {
content: [
{
type: "text",
text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join(
"\n"
)}`,
},
],
};
}
// Check for failure/cancellation
if (taskStatus === "failed" || taskStatus === "cancelled") {
return {
content: [
{
type: "text",
text: `[${taskStatus.toUpperCase()}] ${
taskStatusMessage || "No message"
}\n\nProgress:\n${statusMessages.join("\n")}`,
},
],
};
}
// Fetch the final result
const result = await extra.sendRequest(
{
method: "tasks/result",
params: { taskId },
},
z.any()
);
// Format the elicitation result
const content: CallToolResult["content"] = [];
if (result.action === "accept" && result.content) {
content.push({
type: "text",
text: `[COMPLETED] User provided the requested information!`,
});
const userData = result.content as Record<string, unknown>;
const lines = [];
if (userData.name) lines.push(`- Name: ${userData.name}`);
if (userData.favoriteColor)
lines.push(`- Favorite Color: ${userData.favoriteColor}`);
if (userData.agreeToTerms !== undefined)
lines.push(`- Agreed to terms: ${userData.agreeToTerms}`);
content.push({
type: "text",
text: `User inputs:\n${lines.join("\n")}`,
});
} else if (result.action === "decline") {
content.push({
type: "text",
text: `[DECLINED] User declined to provide the requested information.`,
});
} else if (result.action === "cancel") {
content.push({
type: "text",
text: `[CANCELLED] User cancelled the elicitation dialog.`,
});
}
// Include progress and raw result for debugging
content.push({
type: "text",
text: `\nProgress:\n${statusMessages.join(
"\n"
)}\n\nRaw result: ${JSON.stringify(result, null, 2)}`,
});
return { content };
}
);
}
};

View File

@@ -0,0 +1,229 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
ElicitResultSchema,
CallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
// Tool configuration
const name = "trigger-elicitation-request";
const config = {
title: "Trigger Elicitation Request Tool",
description: "Trigger a Request from the Server for User Elicitation",
inputSchema: {},
};
/**
* Registers the 'trigger-elicitation-request' tool.
*
* If the client does not support the elicitation capability, the tool is not registered.
*
* The registered tool sends an elicitation request for the user to provide information
* based on a pre-defined schema of fields including text inputs, booleans, numbers,
* email, dates, enums of various types, etc. It uses validation and handles multiple
* possible outcomes from the user's response, such as acceptance with content, decline,
* or cancellation of the dialog. The process also ensures parsing and validating
* the elicitation input arguments at runtime.
*
* The elicitation dialog response is returned, formatted into a structured result,
* which contains both user-submitted input data (if provided) and debugging information,
* including raw results.
*
* @param {McpServer} server - TThe McpServer instance where the tool will be registered.
*/
export const registerTriggerElicitationRequestTool = (server: McpServer) => {
// Does the client support elicitation?
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientSupportsElicitation: boolean =
clientCapabilities.elicitation !== undefined;
// If so, register tool
if (clientSupportsElicitation) {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
const elicitationResult = await extra.sendRequest(
{
method: "elicitation/create",
params: {
message: "Please provide inputs for the following fields:",
requestedSchema: {
type: "object",
properties: {
name: {
title: "String",
type: "string",
description: "Your full, legal name",
},
check: {
title: "Boolean",
type: "boolean",
description: "Agree to the terms and conditions",
},
firstLine: {
title: "String with default",
type: "string",
description: "Favorite first line of a story",
default: "It was a dark and stormy night.",
},
email: {
title: "String with email format",
type: "string",
format: "email",
description:
"Your email address (will be verified, and never shared with anyone else)",
},
homepage: {
type: "string",
format: "uri",
title: "String with uri format",
description: "Portfolio / personal website",
},
birthdate: {
title: "String with date format",
type: "string",
format: "date",
description: "Your date of birth",
},
integer: {
title: "Integer",
type: "integer",
description:
"Your favorite integer (do not give us your phone number, pin, or other sensitive info)",
minimum: 1,
maximum: 100,
default: 42,
},
number: {
title: "Number in range 1-1000",
type: "number",
description: "Favorite number (there are no wrong answers)",
minimum: 0,
maximum: 1000,
default: 3.14,
},
untitledSingleSelectEnum: {
type: "string",
title: "Untitled Single Select Enum",
description: "Choose your favorite friend",
enum: [
"Monica",
"Rachel",
"Joey",
"Chandler",
"Ross",
"Phoebe",
],
default: "Monica",
},
untitledMultipleSelectEnum: {
type: "array",
title: "Untitled Multiple Select Enum",
description: "Choose your favorite instruments",
minItems: 1,
maxItems: 3,
items: {
type: "string",
enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"],
},
default: ["Guitar"],
},
titledSingleSelectEnum: {
type: "string",
title: "Titled Single Select Enum",
description: "Choose your favorite hero",
oneOf: [
{ const: "hero-1", title: "Superman" },
{ const: "hero-2", title: "Green Lantern" },
{ const: "hero-3", title: "Wonder Woman" },
],
default: "hero-1",
},
titledMultipleSelectEnum: {
type: "array",
title: "Titled Multiple Select Enum",
description: "Choose your favorite types of fish",
minItems: 1,
maxItems: 3,
items: {
anyOf: [
{ const: "fish-1", title: "Tuna" },
{ const: "fish-2", title: "Salmon" },
{ const: "fish-3", title: "Trout" },
],
},
default: ["fish-1"],
},
legacyTitledEnum: {
type: "string",
title: "Legacy Titled Single Select Enum",
description: "Choose your favorite type of pet",
enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"],
enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"],
default: "pet-1",
},
},
required: ["name"],
},
},
},
ElicitResultSchema,
{ timeout: 10 * 60 * 1000 /* 10 minutes */ }
);
// Handle different response actions
const content: CallToolResult["content"] = [];
if (
elicitationResult.action === "accept" &&
elicitationResult.content
) {
content.push({
type: "text",
text: `✅ User provided the requested information!`,
});
// Only access elicitationResult.content when action is accept
const userData = elicitationResult.content;
const lines = [];
if (userData.name) lines.push(`- Name: ${userData.name}`);
if (userData.check !== undefined)
lines.push(`- Agreed to terms: ${userData.check}`);
if (userData.color) lines.push(`- Favorite Color: ${userData.color}`);
if (userData.email) lines.push(`- Email: ${userData.email}`);
if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`);
if (userData.birthdate)
lines.push(`- Birthdate: ${userData.birthdate}`);
if (userData.integer !== undefined)
lines.push(`- Favorite Integer: ${userData.integer}`);
if (userData.number !== undefined)
lines.push(`- Favorite Number: ${userData.number}`);
if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`);
content.push({
type: "text",
text: `User inputs:\n${lines.join("\n")}`,
});
} else if (elicitationResult.action === "decline") {
content.push({
type: "text",
text: `❌ User declined to provide the requested information.`,
});
} else if (elicitationResult.action === "cancel") {
content.push({
type: "text",
text: `⚠️ User cancelled the elicitation dialog.`,
});
}
// Include raw result for debugging
content.push({
type: "text",
text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`,
});
return { content };
}
);
}
};

View File

@@ -0,0 +1,76 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
// Tool input schema
const TriggerLongRunningOperationSchema = z.object({
duration: z
.number()
.default(10)
.describe("Duration of the operation in seconds"),
steps: z.number().default(5).describe("Number of steps in the operation"),
});
// Tool configuration
const name = "trigger-long-running-operation";
const config = {
title: "Trigger Long Running Operation Tool",
description: "Demonstrates a long running operation with progress updates.",
inputSchema: TriggerLongRunningOperationSchema,
};
/**
* Registers the 'trigger-tong-running-operation' tool.
*
* The registered tool starts a long-running operation defined by a specific duration and
* number of steps.
*
* Progress notifications are sent back to the client at each step if a `progressToken`
* is provided in the metadata.
*
* At the end of the operation, the tool returns a message indicating the completion of the
* operation, including the total duration and steps.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerTriggerLongRunningOperationTool = (server: McpServer) => {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
const validatedArgs = TriggerLongRunningOperationSchema.parse(args);
const { duration, steps } = validatedArgs;
const stepDuration = duration / steps;
const progressToken = extra._meta?.progressToken;
for (let i = 1; i < steps + 1; i++) {
await new Promise((resolve) =>
setTimeout(resolve, stepDuration * 1000)
);
if (progressToken !== undefined) {
await server.server.notification(
{
method: "notifications/progress",
params: {
progress: i,
total: steps,
progressToken,
},
},
{ relatedRequestId: extra.requestId }
);
}
}
return {
content: [
{
type: "text",
text: `Long running operation completed. Duration: ${duration} seconds, Steps: ${steps}.`,
},
],
};
}
);
};

View File

@@ -0,0 +1,230 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolResult,
CreateMessageRequest,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Tool input schema
const TriggerSamplingRequestAsyncSchema = z.object({
prompt: z.string().describe("The prompt to send to the LLM"),
maxTokens: z
.number()
.default(100)
.describe("Maximum number of tokens to generate"),
});
// Tool configuration
const name = "trigger-sampling-request-async";
const config = {
title: "Trigger Async Sampling Request Tool",
description:
"Trigger an async sampling request that the CLIENT executes as a background task. " +
"Demonstrates bidirectional MCP tasks where the server sends a request and the client " +
"executes it asynchronously, allowing the server to poll for progress and results.",
inputSchema: TriggerSamplingRequestAsyncSchema,
};
// Poll interval in milliseconds
const POLL_INTERVAL = 1000;
// Maximum poll attempts before timeout
const MAX_POLL_ATTEMPTS = 60;
/**
* Registers the 'trigger-sampling-request-async' tool.
*
* This tool demonstrates bidirectional MCP tasks:
* - Server sends sampling request to client with task metadata
* - Client creates a task and returns CreateTaskResult
* - Server polls client's tasks/get endpoint for status
* - Server fetches final result from client's tasks/result endpoint
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => {
// Check client capabilities
const clientCapabilities = server.server.getClientCapabilities() || {};
// Client must support sampling AND tasks.requests.sampling
const clientSupportsSampling = clientCapabilities.sampling !== undefined;
const clientTasksCapability = clientCapabilities.tasks as
| {
requests?: { sampling?: { createMessage?: object } };
}
| undefined;
const clientSupportsAsyncSampling =
clientTasksCapability?.requests?.sampling?.createMessage !== undefined;
if (clientSupportsSampling && clientSupportsAsyncSampling) {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
const validatedArgs = TriggerSamplingRequestAsyncSchema.parse(args);
const { prompt, maxTokens } = validatedArgs;
// Create the sampling request WITH task metadata
// The params.task field signals to the client that this should be executed as a task
const request: CreateMessageRequest & {
params: { task?: { ttl: number } };
} = {
method: "sampling/createMessage",
params: {
task: {
ttl: 300000, // 5 minutes
},
messages: [
{
role: "user",
content: {
type: "text",
text: `Resource ${name} context: ${prompt}`,
},
},
],
systemPrompt: "You are a helpful test server.",
maxTokens,
temperature: 0.7,
},
};
// Send the sampling request
// Client may return either:
// - CreateMessageResult (synchronous execution)
// - CreateTaskResult (task-based execution with { task } object)
const samplingResponse = await extra.sendRequest(
request,
z.union([
// CreateTaskResult - client created a task
z.object({
task: z.object({
taskId: z.string(),
status: z.string(),
pollInterval: z.number().optional(),
statusMessage: z.string().optional(),
}),
}),
// CreateMessageResult - synchronous execution
z.object({
role: z.string(),
content: z.any(),
model: z.string(),
stopReason: z.string().optional(),
}),
])
);
// Check if client returned CreateTaskResult (has task object)
const isTaskResult =
"task" in samplingResponse && samplingResponse.task;
if (!isTaskResult) {
// Client executed synchronously - return the direct response
return {
content: [
{
type: "text",
text: `[SYNC] Client executed synchronously:\n${JSON.stringify(
samplingResponse,
null,
2
)}`,
},
],
};
}
const taskId = samplingResponse.task.taskId;
const statusMessages: string[] = [];
statusMessages.push(`Task created: ${taskId}`);
// Poll for task completion
let attempts = 0;
let taskStatus = samplingResponse.task.status;
let taskStatusMessage: string | undefined;
while (
taskStatus !== "completed" &&
taskStatus !== "failed" &&
taskStatus !== "cancelled" &&
attempts < MAX_POLL_ATTEMPTS
) {
// Wait before polling
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
attempts++;
// Get task status from client
const pollResult = await extra.sendRequest(
{
method: "tasks/get",
params: { taskId },
},
z
.object({
status: z.string(),
statusMessage: z.string().optional(),
})
.passthrough()
);
taskStatus = pollResult.status;
taskStatusMessage = pollResult.statusMessage;
statusMessages.push(
`Poll ${attempts}: ${taskStatus}${
taskStatusMessage ? ` - ${taskStatusMessage}` : ""
}`
);
}
// Check for timeout
if (attempts >= MAX_POLL_ATTEMPTS) {
return {
content: [
{
type: "text",
text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join(
"\n"
)}`,
},
],
};
}
// Check for failure/cancellation
if (taskStatus === "failed" || taskStatus === "cancelled") {
return {
content: [
{
type: "text",
text: `[${taskStatus.toUpperCase()}] ${
taskStatusMessage || "No message"
}\n\nProgress:\n${statusMessages.join("\n")}`,
},
],
};
}
// Fetch the final result
const result = await extra.sendRequest(
{
method: "tasks/result",
params: { taskId },
},
z.any()
);
// Return the result with status history
return {
content: [
{
type: "text",
text: `[COMPLETED] Async sampling completed!\n\n**Progress:**\n${statusMessages.join(
"\n"
)}\n\n**Result:**\n${JSON.stringify(result, null, 2)}`,
},
],
};
}
);
}
};

View File

@@ -0,0 +1,91 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
CallToolResult,
CreateMessageRequest,
CreateMessageResultSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Tool input schema
const TriggerSamplingRequestSchema = z.object({
prompt: z.string().describe("The prompt to send to the LLM"),
maxTokens: z
.number()
.default(100)
.describe("Maximum number of tokens to generate"),
});
// Tool configuration
const name = "trigger-sampling-request";
const config = {
title: "Trigger Sampling Request Tool",
description: "Trigger a Request from the Server for LLM Sampling",
inputSchema: TriggerSamplingRequestSchema,
};
/**
* Registers the 'trigger-sampling-request' tool.
*
* If the client does not support the sampling capability, the tool is not registered.
*
* The registered tool performs the following operations:
* - Validates incoming arguments using `TriggerSamplingRequestSchema`.
* - Constructs a `sampling/createMessage` request object using provided prompt and maximum tokens.
* - Sends the request to the server for sampling.
* - Formats and returns the sampling result content to the client.
*
* @param {McpServer} server - The McpServer instance where the tool will be registered.
*/
export const registerTriggerSamplingRequestTool = (server: McpServer) => {
// Does the client support sampling?
const clientCapabilities = server.server.getClientCapabilities() || {};
const clientSupportsSampling: boolean =
clientCapabilities.sampling !== undefined;
// If so, register tool
if (clientSupportsSampling) {
server.registerTool(
name,
config,
async (args, extra): Promise<CallToolResult> => {
const validatedArgs = TriggerSamplingRequestSchema.parse(args);
const { prompt, maxTokens } = validatedArgs;
// Create the sampling request
const request: CreateMessageRequest = {
method: "sampling/createMessage",
params: {
messages: [
{
role: "user",
content: {
type: "text",
text: `Resource ${name} context: ${prompt}`,
},
},
],
systemPrompt: "You are a helpful test server.",
maxTokens,
temperature: 0.7,
},
};
// Send the sampling request to the client
const result = await extra.sendRequest(
request,
CreateMessageResultSchema
);
// Return the result to the client
return {
content: [
{
type: "text",
text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`,
},
],
};
}
);
}
};

View File

@@ -0,0 +1,77 @@
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import { createServer } from "../server/index.js";
import cors from "cors";
console.error("Starting SSE server...");
// Express app with permissive CORS for testing with Inspector direct connect mode
const app = express();
app.use(
cors({
origin: "*", // use "*" with caution in production
methods: "GET,POST",
preflightContinue: false,
optionsSuccessStatus: 204,
})
);
// Map sessionId to transport for each client
const transports: Map<string, SSEServerTransport> = new Map<
string,
SSEServerTransport
>();
// Handle GET requests for new SSE streams
app.get("/sse", async (req, res) => {
let transport: SSEServerTransport;
const { server, cleanup } = createServer();
// Session Id should not exist for GET /sse requests
if (req?.query?.sessionId) {
const sessionId = req?.query?.sessionId as string;
transport = transports.get(sessionId) as SSEServerTransport;
console.error(
"Client Reconnecting? This shouldn't happen; when client has a sessionId, GET /sse should not be called again.",
transport.sessionId
);
} else {
// Create and store transport for the new session
transport = new SSEServerTransport("/message", res);
transports.set(transport.sessionId, transport);
// Connect server to transport
await server.connect(transport);
const sessionId = transport.sessionId;
console.error("Client Connected: ", sessionId);
// Handle close of connection
server.server.onclose = async () => {
const sessionId = transport.sessionId;
console.error("Client Disconnected: ", sessionId);
transports.delete(sessionId);
cleanup(sessionId);
};
}
});
// Handle POST requests for client messages
app.post("/message", async (req, res) => {
// Session Id should exist for POST /message requests
const sessionId = req?.query?.sessionId as string;
// Get the transport for this session and use it to handle the request
const transport = transports.get(sessionId);
if (transport) {
console.error("Client Message from", sessionId);
await transport.handlePostMessage(req, res);
} else {
console.error(`No transport found for sessionId ${sessionId}`);
}
});
// Start the express server
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.error(`Server is running on port ${PORT}`);
});

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "../server/index.js";
console.error("Starting default (STDIO) server...");
/**
* The main method
* - Initializes the StdioServerTransport, sets up the server,
* - Handles cleanup on process exit.
*
* @return {Promise<void>} A promise that resolves when the main function has executed and the process exits.
*/
async function main(): Promise<void> {
const transport = new StdioServerTransport();
const { server, cleanup } = createServer();
// Connect transport to server
await server.connect(transport);
// Cleanup on exit
process.on("SIGINT", async () => {
await server.close();
cleanup();
process.exit(0);
});
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,240 @@
import {
StreamableHTTPServerTransport,
EventStore,
} from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express, { Request, Response } from "express";
import { createServer } from "../server/index.js";
import { randomUUID } from "node:crypto";
import cors from "cors";
// Simple in-memory event store for SSE resumability
class InMemoryEventStore implements EventStore {
private events: Map<string, { streamId: string; message: unknown }> =
new Map();
async storeEvent(streamId: string, message: unknown): Promise<string> {
const eventId = randomUUID();
this.events.set(eventId, { streamId, message });
return eventId;
}
async replayEventsAfter(
lastEventId: string,
{ send }: { send: (eventId: string, message: unknown) => Promise<void> }
): Promise<string> {
const entries = Array.from(this.events.entries());
const startIndex = entries.findIndex(([id]) => id === lastEventId);
if (startIndex === -1) return lastEventId;
let lastId: string = lastEventId;
for (let i = startIndex + 1; i < entries.length; i++) {
const [eventId, { message }] = entries[i];
await send(eventId, message);
lastId = eventId;
}
return lastId;
}
}
console.log("Starting Streamable HTTP server...");
// Express app with permissive CORS for testing with Inspector direct connect mode
const app = express();
app.use(
cors({
origin: "*", // use "*" with caution in production
methods: "GET,POST,DELETE",
preflightContinue: false,
optionsSuccessStatus: 204,
exposedHeaders: ["mcp-session-id", "last-event-id", "mcp-protocol-version"],
})
);
// Map sessionId to server transport for each client
const transports: Map<string, StreamableHTTPServerTransport> = new Map<
string,
StreamableHTTPServerTransport
>();
// Handle POST requests for client messages
app.post("/mcp", async (req: Request, res: Response) => {
console.log("Received MCP POST request");
try {
// Check for existing session ID
const sessionId = req.headers["mcp-session-id"] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports.has(sessionId)) {
// Reuse existing transport
transport = transports.get(sessionId)!;
} else if (!sessionId) {
const { server, cleanup } = createServer();
// New initialization request
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore, // Enable resumability
onsessioninitialized: (sessionId: string) => {
// Store the transport by session ID when a session is initialized
// This avoids race conditions where requests might come in before the session is stored
console.log(`Session initialized with ID: ${sessionId}`);
transports.set(sessionId, transport);
},
});
// Set up onclose handler to clean up transport when closed
server.server.onclose = async () => {
const sid = transport.sessionId;
if (sid && transports.has(sid)) {
console.log(
`Transport closed for session ${sid}, removing from transports map`
);
transports.delete(sid);
cleanup(sid);
}
};
// Connect the transport to the MCP server BEFORE handling the request
// so responses can flow back through the same transport
await server.connect(transport);
await transport.handleRequest(req, res);
return;
} else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
// Handle the request with existing transport - no need to reconnect
// The existing transport is already connected to the server
await transport.handleRequest(req, res);
} catch (error) {
console.log("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: req?.body?.id,
});
return;
}
}
});
// Handle GET requests for SSE streams
app.get("/mcp", async (req: Request, res: Response) => {
console.log("Received MCP GET request");
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports.has(sessionId)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers["last-event-id"] as string | undefined;
if (lastEventId) {
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
console.log(`Establishing new SSE stream for session ${sessionId}`);
}
const transport = transports.get(sessionId);
await transport!.handleRequest(req, res);
});
// Handle DELETE requests for session termination
app.delete("/mcp", async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports.has(sessionId)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
console.log(`Received session termination request for session ${sessionId}`);
try {
const transport = transports.get(sessionId);
await transport!.handleRequest(req, res);
} catch (error) {
console.log("Error handling session termination:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Error handling session termination",
},
id: req?.body?.id,
});
return;
}
}
});
// Start the server
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.error(`MCP Streamable HTTP Server listening on port ${PORT}`);
});
// Handle server errors
server.on("error", (err: unknown) => {
const code =
typeof err === "object" && err !== null && "code" in err
? (err as { code?: unknown }).code
: undefined;
if (code === "EADDRINUSE") {
console.error(
`Failed to start: Port ${PORT} is already in use. Set PORT to a free port or stop the conflicting process.`
);
} else {
console.error("HTTP server encountered an error while starting:", err);
}
// Ensure a non-zero exit so npm reports the failure instead of silently exiting
process.exit(1);
});
// Handle server shutdown
process.on("SIGINT", async () => {
console.log("Shutting down server...");
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
console.log(`Closing transport for session ${sessionId}`);
await transports.get(sessionId)!.close();
transports.delete(sessionId);
} catch (error) {
console.log(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log("Server shutdown complete");
process.exit(0);
});

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "."
},
"include": ["./**/*.ts"]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['**/__tests__/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['**/*.ts'],
exclude: ['**/__tests__/**', '**/dist/**'],
},
},
});

View File

@@ -0,0 +1 @@
3.11

View File

@@ -0,0 +1,36 @@
# Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv
# Install the project into `/app`
WORKDIR /app
# Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
# Copy from the cache instead of linking since it's a mounted volume
ENV UV_LINK_MODE=copy
# Install the project's dependencies using the lockfile and settings
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev --no-editable
# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
ADD . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-editable
FROM python:3.12-slim-bookworm
WORKDIR /app
COPY --from=uv /root/.local /root/.local
COPY --from=uv --chown=app:app /app/.venv /app/.venv
# Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
# when running the container, add --db-path and a bind mount to the host's db file
ENTRYPOINT ["mcp-server-fetch"]

View File

@@ -0,0 +1,7 @@
Copyright (c) 2024 Anthropic, PBC.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,241 @@
# Fetch MCP Server
<!-- mcp-name: io.github.modelcontextprotocol/server-fetch -->
A Model Context Protocol server that provides web content fetching capabilities. This server enables LLMs to retrieve and process content from web pages, converting HTML to markdown for easier consumption.
> [!CAUTION]
> This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data.
The fetch tool will truncate the response, but by using the `start_index` argument, you can specify where to start the content extraction. This lets models read a webpage in chunks, until they find the information they need.
### Available Tools
- `fetch` - Fetches a URL from the internet and extracts its contents as markdown.
- `url` (string, required): URL to fetch
- `max_length` (integer, optional): Maximum number of characters to return (default: 5000)
- `start_index` (integer, optional): Start content from this character index (default: 0)
- `raw` (boolean, optional): Get raw content without markdown conversion (default: false)
### Prompts
- **fetch**
- Fetch a URL and extract its contents as markdown
- Arguments:
- `url` (string, required): URL to fetch
## Installation
Optionally: Install node.js, this will cause the fetch server to use a different HTML simplifier that is more robust.
### Using uv (recommended)
When using [`uv`](https://docs.astral.sh/uv/) no specific installation is needed. We will
use [`uvx`](https://docs.astral.sh/uv/guides/tools/) to directly run *mcp-server-fetch*.
### Using PIP
Alternatively you can install `mcp-server-fetch` via pip:
```
pip install mcp-server-fetch
```
After installation, you can run it as a script using:
```
python -m mcp_server_fetch
```
## Configuration
### Configure for Claude.app
Add to your Claude settings:
<details>
<summary>Using uvx</summary>
```json
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
```
</details>
<details>
<summary>Using docker</summary>
```json
{
"mcpServers": {
"fetch": {
"command": "docker",
"args": ["run", "-i", "--rm", "mcp/fetch"]
}
}
}
```
</details>
<details>
<summary>Using pip installation</summary>
```json
{
"mcpServers": {
"fetch": {
"command": "python",
"args": ["-m", "mcp_server_fetch"]
}
}
}
```
</details>
### Configure for VS Code
For quick installation, use one of the one-click install buttons below...
[![Install with UV in VS Code](https://img.shields.io/badge/VS_Code-UV-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=fetch&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D) [![Install with UV in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-UV-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=fetch&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-fetch%22%5D%7D&quality=insiders)
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Docker-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=fetch&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22mcp%2Ffetch%22%5D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Docker-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=fetch&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22mcp%2Ffetch%22%5D%7D&quality=insiders)
For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
> Note that the `mcp` key is needed when using the `mcp.json` file.
<details>
<summary>Using uvx</summary>
```json
{
"mcp": {
"servers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}
}
```
</details>
<details>
<summary>Using Docker</summary>
```json
{
"mcp": {
"servers": {
"fetch": {
"command": "docker",
"args": ["run", "-i", "--rm", "mcp/fetch"]
}
}
}
}
```
</details>
### Customization - robots.txt
By default, the server will obey a websites robots.txt file if the request came from the model (via a tool), but not if
the request was user initiated (via a prompt). This can be disabled by adding the argument `--ignore-robots-txt` to the
`args` list in the configuration.
### Customization - User-agent
By default, depending on if the request came from the model (via a tool), or was user initiated (via a prompt), the
server will use either the user-agent
```
ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)
```
or
```
ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)
```
This can be customized by adding the argument `--user-agent=YourUserAgent` to the `args` list in the configuration.
### Customization - Proxy
The server can be configured to use a proxy by using the `--proxy-url` argument.
## Windows Configuration
If you're experiencing timeout issues on Windows, you may need to set the `PYTHONIOENCODING` environment variable to ensure proper character encoding:
<details>
<summary>Windows configuration (uvx)</summary>
```json
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {
"PYTHONIOENCODING": "utf-8"
}
}
}
}
```
</details>
<details>
<summary>Windows configuration (pip)</summary>
```json
{
"mcpServers": {
"fetch": {
"command": "python",
"args": ["-m", "mcp_server_fetch"],
"env": {
"PYTHONIOENCODING": "utf-8"
}
}
}
}
```
</details>
This addresses character encoding issues that can cause the server to timeout on Windows systems.
## Debugging
You can use the MCP inspector to debug the server. For uvx installations:
```
npx @modelcontextprotocol/inspector uvx mcp-server-fetch
```
Or if you've installed the package in a specific directory or are developing on it:
```
cd path/to/servers/src/fetch
npx @modelcontextprotocol/inspector uv run mcp-server-fetch
```
## Contributing
We encourage contributions to help expand and improve mcp-server-fetch. Whether you want to add new tools, enhance existing functionality, or improve documentation, your input is valuable.
For examples of other MCP servers and implementation patterns, see:
https://github.com/modelcontextprotocol/servers
Pull requests are welcome! Feel free to contribute new ideas, bug fixes, or enhancements to make mcp-server-fetch even more powerful and useful.
## License
mcp-server-fetch is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.

View File

@@ -0,0 +1,40 @@
[project]
name = "mcp-server-fetch"
version = "0.6.3"
description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
maintainers = [{ name = "Jack Adamson", email = "jadamson@anthropic.com" }]
keywords = ["http", "mcp", "llm", "automation"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = [
"httpx>=0.27",
"markdownify>=0.13.1",
"mcp>=1.1.3",
"protego>=0.3.1",
"pydantic>=2.0.0",
"readabilipy>=0.2.0",
"requests>=2.32.3",
]
[project.scripts]
mcp-server-fetch = "mcp_server_fetch:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3", "pytest>=8.0.0", "pytest-asyncio>=0.21.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

View File

@@ -0,0 +1,25 @@
from .server import serve
def main():
"""MCP Fetch Server - HTTP fetching functionality for MCP"""
import argparse
import asyncio
parser = argparse.ArgumentParser(
description="give a model the ability to make web requests"
)
parser.add_argument("--user-agent", type=str, help="Custom User-Agent string")
parser.add_argument(
"--ignore-robots-txt",
action="store_true",
help="Ignore robots.txt restrictions",
)
parser.add_argument("--proxy-url", type=str, help="Proxy URL to use for requests")
args = parser.parse_args()
asyncio.run(serve(args.user_agent, args.ignore_robots_txt, args.proxy_url))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
# __main__.py
from mcp_server_fetch import main
main()

View File

@@ -0,0 +1,288 @@
from typing import Annotated, Tuple
from urllib.parse import urlparse, urlunparse
import markdownify
import readabilipy.simple_json
from mcp.shared.exceptions import McpError
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
ErrorData,
GetPromptResult,
Prompt,
PromptArgument,
PromptMessage,
TextContent,
Tool,
INVALID_PARAMS,
INTERNAL_ERROR,
)
from protego import Protego
from pydantic import BaseModel, Field, AnyUrl
DEFAULT_USER_AGENT_AUTONOMOUS = "ModelContextProtocol/1.0 (Autonomous; +https://github.com/modelcontextprotocol/servers)"
DEFAULT_USER_AGENT_MANUAL = "ModelContextProtocol/1.0 (User-Specified; +https://github.com/modelcontextprotocol/servers)"
def extract_content_from_html(html: str) -> str:
"""Extract and convert HTML content to Markdown format.
Args:
html: Raw HTML content to process
Returns:
Simplified markdown version of the content
"""
ret = readabilipy.simple_json.simple_json_from_html_string(
html, use_readability=True
)
if not ret["content"]:
return "<error>Page failed to be simplified from HTML</error>"
content = markdownify.markdownify(
ret["content"],
heading_style=markdownify.ATX,
)
return content
def get_robots_txt_url(url: str) -> str:
"""Get the robots.txt URL for a given website URL.
Args:
url: Website URL to get robots.txt for
Returns:
URL of the robots.txt file
"""
# Parse the URL into components
parsed = urlparse(url)
# Reconstruct the base URL with just scheme, netloc, and /robots.txt path
robots_url = urlunparse((parsed.scheme, parsed.netloc, "/robots.txt", "", "", ""))
return robots_url
async def check_may_autonomously_fetch_url(url: str, user_agent: str, proxy_url: str | None = None) -> None:
"""
Check if the URL can be fetched by the user agent according to the robots.txt file.
Raises a McpError if not.
"""
from httpx import AsyncClient, HTTPError
robot_txt_url = get_robots_txt_url(url)
async with AsyncClient(proxy=proxy_url) as client:
try:
response = await client.get(
robot_txt_url,
follow_redirects=True,
headers={"User-Agent": user_agent},
)
except HTTPError:
raise McpError(ErrorData(
code=INTERNAL_ERROR,
message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue",
))
if response.status_code in (401, 403):
raise McpError(ErrorData(
code=INTERNAL_ERROR,
message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt",
))
elif 400 <= response.status_code < 500:
return
robot_txt = response.text
processed_robot_txt = "\n".join(
line for line in robot_txt.splitlines() if not line.strip().startswith("#")
)
robot_parser = Protego.parse(processed_robot_txt)
if not robot_parser.can_fetch(str(url), user_agent):
raise McpError(ErrorData(
code=INTERNAL_ERROR,
message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, "
f"<useragent>{user_agent}</useragent>\n"
f"<url>{url}</url>"
f"<robots>\n{robot_txt}\n</robots>\n"
f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n"
f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.",
))
async def fetch_url(
url: str, user_agent: str, force_raw: bool = False, proxy_url: str | None = None
) -> Tuple[str, str]:
"""
Fetch the URL and return the content in a form ready for the LLM, as well as a prefix string with status information.
"""
from httpx import AsyncClient, HTTPError
async with AsyncClient(proxy=proxy_url) as client:
try:
response = await client.get(
url,
follow_redirects=True,
headers={"User-Agent": user_agent},
timeout=30,
)
except HTTPError as e:
raise McpError(ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}"))
if response.status_code >= 400:
raise McpError(ErrorData(
code=INTERNAL_ERROR,
message=f"Failed to fetch {url} - status code {response.status_code}",
))
page_raw = response.text
content_type = response.headers.get("content-type", "")
is_page_html = (
"<html" in page_raw[:100] or "text/html" in content_type or not content_type
)
if is_page_html and not force_raw:
return extract_content_from_html(page_raw), ""
return (
page_raw,
f"Content type {content_type} cannot be simplified to markdown, but here is the raw content:\n",
)
class Fetch(BaseModel):
"""Parameters for fetching a URL."""
url: Annotated[AnyUrl, Field(description="URL to fetch")]
max_length: Annotated[
int,
Field(
default=5000,
description="Maximum number of characters to return.",
gt=0,
lt=1000000,
),
]
start_index: Annotated[
int,
Field(
default=0,
description="On return output starting at this character index, useful if a previous fetch was truncated and more context is required.",
ge=0,
),
]
raw: Annotated[
bool,
Field(
default=False,
description="Get the actual HTML content of the requested page, without simplification.",
),
]
async def serve(
custom_user_agent: str | None = None,
ignore_robots_txt: bool = False,
proxy_url: str | None = None,
) -> None:
"""Run the fetch MCP server.
Args:
custom_user_agent: Optional custom User-Agent string to use for requests
ignore_robots_txt: Whether to ignore robots.txt restrictions
proxy_url: Optional proxy URL to use for requests
"""
server = Server("mcp-fetch")
user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS
user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="fetch",
description="""Fetches a URL from the internet and optionally extracts its contents as markdown.
Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""",
inputSchema=Fetch.model_json_schema(),
)
]
@server.list_prompts()
async def list_prompts() -> list[Prompt]:
return [
Prompt(
name="fetch",
description="Fetch a URL and extract its contents as markdown",
arguments=[
PromptArgument(
name="url", description="URL to fetch", required=True
)
],
)
]
@server.call_tool()
async def call_tool(name, arguments: dict) -> list[TextContent]:
try:
args = Fetch(**arguments)
except ValueError as e:
raise McpError(ErrorData(code=INVALID_PARAMS, message=str(e)))
url = str(args.url)
if not url:
raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required"))
if not ignore_robots_txt:
await check_may_autonomously_fetch_url(url, user_agent_autonomous, proxy_url)
content, prefix = await fetch_url(
url, user_agent_autonomous, force_raw=args.raw, proxy_url=proxy_url
)
original_length = len(content)
if args.start_index >= original_length:
content = "<error>No more content available.</error>"
else:
truncated_content = content[args.start_index : args.start_index + args.max_length]
if not truncated_content:
content = "<error>No more content available.</error>"
else:
content = truncated_content
actual_content_length = len(truncated_content)
remaining_content = original_length - (args.start_index + actual_content_length)
# Only add the prompt to continue fetching if there is still remaining content
if actual_content_length == args.max_length and remaining_content > 0:
next_start = args.start_index + actual_content_length
content += f"\n\n<error>Content truncated. Call the fetch tool with a start_index of {next_start} to get more content.</error>"
return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")]
@server.get_prompt()
async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult:
if not arguments or "url" not in arguments:
raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required"))
url = arguments["url"]
try:
content, prefix = await fetch_url(url, user_agent_manual, proxy_url=proxy_url)
# TODO: after SDK bug is addressed, don't catch the exception
except McpError as e:
return GetPromptResult(
description=f"Failed to fetch {url}",
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text=str(e)),
)
],
)
return GetPromptResult(
description=f"Contents of {url}",
messages=[
PromptMessage(
role="user", content=TextContent(type="text", text=prefix + content)
)
],
)
options = server.create_initialization_options()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, options, raise_exceptions=False)

View File

@@ -0,0 +1,326 @@
"""Tests for the fetch MCP server."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from mcp.shared.exceptions import McpError
from mcp_server_fetch.server import (
extract_content_from_html,
get_robots_txt_url,
check_may_autonomously_fetch_url,
fetch_url,
DEFAULT_USER_AGENT_AUTONOMOUS,
)
class TestGetRobotsTxtUrl:
"""Tests for get_robots_txt_url function."""
def test_simple_url(self):
"""Test with a simple URL."""
result = get_robots_txt_url("https://example.com/page")
assert result == "https://example.com/robots.txt"
def test_url_with_path(self):
"""Test with URL containing path."""
result = get_robots_txt_url("https://example.com/some/deep/path/page.html")
assert result == "https://example.com/robots.txt"
def test_url_with_query_params(self):
"""Test with URL containing query parameters."""
result = get_robots_txt_url("https://example.com/page?foo=bar&baz=qux")
assert result == "https://example.com/robots.txt"
def test_url_with_port(self):
"""Test with URL containing port number."""
result = get_robots_txt_url("https://example.com:8080/page")
assert result == "https://example.com:8080/robots.txt"
def test_url_with_fragment(self):
"""Test with URL containing fragment."""
result = get_robots_txt_url("https://example.com/page#section")
assert result == "https://example.com/robots.txt"
def test_http_url(self):
"""Test with HTTP URL."""
result = get_robots_txt_url("http://example.com/page")
assert result == "http://example.com/robots.txt"
class TestExtractContentFromHtml:
"""Tests for extract_content_from_html function."""
def test_simple_html(self):
"""Test with simple HTML content."""
html = """
<html>
<head><title>Test Page</title></head>
<body>
<article>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
</article>
</body>
</html>
"""
result = extract_content_from_html(html)
# readabilipy may extract different parts depending on the content
assert "test paragraph" in result
def test_html_with_links(self):
"""Test that links are converted to markdown."""
html = """
<html>
<body>
<article>
<p>Visit <a href="https://example.com">Example</a> for more.</p>
</article>
</body>
</html>
"""
result = extract_content_from_html(html)
assert "Example" in result
def test_empty_content_returns_error(self):
"""Test that empty/invalid HTML returns error message."""
html = ""
result = extract_content_from_html(html)
assert "<error>" in result
class TestCheckMayAutonomouslyFetchUrl:
"""Tests for check_may_autonomously_fetch_url function."""
@pytest.mark.asyncio
async def test_allows_when_robots_txt_404(self):
"""Test that fetching is allowed when robots.txt returns 404."""
mock_response = MagicMock()
mock_response.status_code = 404
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
# Should not raise
await check_may_autonomously_fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS
)
@pytest.mark.asyncio
async def test_blocks_when_robots_txt_401(self):
"""Test that fetching is blocked when robots.txt returns 401."""
mock_response = MagicMock()
mock_response.status_code = 401
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(McpError):
await check_may_autonomously_fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS
)
@pytest.mark.asyncio
async def test_blocks_when_robots_txt_403(self):
"""Test that fetching is blocked when robots.txt returns 403."""
mock_response = MagicMock()
mock_response.status_code = 403
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(McpError):
await check_may_autonomously_fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS
)
@pytest.mark.asyncio
async def test_allows_when_robots_txt_allows_all(self):
"""Test that fetching is allowed when robots.txt allows all."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "User-agent: *\nAllow: /"
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
# Should not raise
await check_may_autonomously_fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS
)
@pytest.mark.asyncio
async def test_blocks_when_robots_txt_disallows_all(self):
"""Test that fetching is blocked when robots.txt disallows all."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "User-agent: *\nDisallow: /"
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(McpError):
await check_may_autonomously_fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS
)
class TestFetchUrl:
"""Tests for fetch_url function."""
@pytest.mark.asyncio
async def test_fetch_html_page(self):
"""Test fetching an HTML page returns markdown content."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = """
<html>
<body>
<article>
<h1>Test Page</h1>
<p>Hello World</p>
</article>
</body>
</html>
"""
mock_response.headers = {"content-type": "text/html"}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
content, prefix = await fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS
)
# HTML is processed, so we check it returns something
assert isinstance(content, str)
assert prefix == ""
@pytest.mark.asyncio
async def test_fetch_html_page_raw(self):
"""Test fetching an HTML page with raw=True returns original HTML."""
html_content = "<html><body><h1>Test</h1></body></html>"
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = html_content
mock_response.headers = {"content-type": "text/html"}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
content, prefix = await fetch_url(
"https://example.com/page",
DEFAULT_USER_AGENT_AUTONOMOUS,
force_raw=True
)
assert content == html_content
assert "cannot be simplified" in prefix
@pytest.mark.asyncio
async def test_fetch_json_returns_raw(self):
"""Test fetching JSON content returns raw content."""
json_content = '{"key": "value"}'
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = json_content
mock_response.headers = {"content-type": "application/json"}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
content, prefix = await fetch_url(
"https://api.example.com/data",
DEFAULT_USER_AGENT_AUTONOMOUS
)
assert content == json_content
assert "cannot be simplified" in prefix
@pytest.mark.asyncio
async def test_fetch_404_raises_error(self):
"""Test that 404 response raises McpError."""
mock_response = MagicMock()
mock_response.status_code = 404
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(McpError):
await fetch_url(
"https://example.com/notfound",
DEFAULT_USER_AGENT_AUTONOMOUS
)
@pytest.mark.asyncio
async def test_fetch_500_raises_error(self):
"""Test that 500 response raises McpError."""
mock_response = MagicMock()
mock_response.status_code = 500
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(McpError):
await fetch_url(
"https://example.com/error",
DEFAULT_USER_AGENT_AUTONOMOUS
)
@pytest.mark.asyncio
async def test_fetch_with_proxy(self):
"""Test that proxy URL is passed to client."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = '{"data": "test"}'
mock_response.headers = {"content-type": "application/json"}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_class.return_value.__aexit__ = AsyncMock(return_value=None)
await fetch_url(
"https://example.com/data",
DEFAULT_USER_AGENT_AUTONOMOUS,
proxy_url="http://proxy.example.com:8080"
)
# Verify AsyncClient was called with proxy
mock_client_class.assert_called_once_with(proxy="http://proxy.example.com:8080")

1285
.agent/services/mcp-core/src/fetch/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
FROM node:22.12-alpine AS builder
WORKDIR /app
COPY src/filesystem /app
COPY tsconfig.json /tsconfig.json
RUN --mount=type=cache,target=/root/.npm npm install
RUN --mount=type=cache,target=/root/.npm-production npm ci --ignore-scripts --omit-dev
FROM node:22-alpine AS release
WORKDIR /app
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/package-lock.json /app/package-lock.json
ENV NODE_ENV=production
RUN npm ci --ignore-scripts --omit-dev
ENTRYPOINT ["node", "/app/dist/index.js"]

View File

@@ -0,0 +1,321 @@
# Filesystem MCP Server
Node.js server implementing Model Context Protocol (MCP) for filesystem operations.
## Features
- Read/write files
- Create/list/delete directories
- Move files/directories
- Search files
- Get file metadata
- Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots)
## Directory Access Control
The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots).
### Method 1: Command-line Arguments
Specify Allowed directories when starting the server:
```bash
mcp-server-filesystem /path/to/dir1 /path/to/dir2
```
### Method 2: MCP Roots (Recommended)
MCP clients that support [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) can dynamically update the Allowed directories.
Roots notified by Client to Server, completely replace any server-side Allowed directories when provided.
**Important**: If server starts without command-line arguments AND client doesn't support roots protocol (or provides empty roots), the server will throw an error during initialization.
This is the recommended method, as this enables runtime directory updates via `roots/list_changed` notifications without server restart, providing a more flexible and modern integration experience.
### How It Works
The server's directory access control follows this flow:
1. **Server Startup**
- Server starts with directories from command-line arguments (if provided)
- If no arguments provided, server starts with empty allowed directories
2. **Client Connection & Initialization**
- Client connects and sends `initialize` request with capabilities
- Server checks if client supports roots protocol (`capabilities.roots`)
3. **Roots Protocol Handling** (if client supports roots)
- **On initialization**: Server requests roots from client via `roots/list`
- Client responds with its configured roots
- Server replaces ALL allowed directories with client's roots
- **On runtime updates**: Client can send `notifications/roots/list_changed`
- Server requests updated roots and replaces allowed directories again
4. **Fallback Behavior** (if client doesn't support roots)
- Server continues using command-line directories only
- No dynamic updates possible
5. **Access Control**
- All filesystem operations are restricted to allowed directories
- Use `list_allowed_directories` tool to see current directories
- Server requires at least ONE allowed directory to operate
**Note**: The server will only allow operations within directories specified either via `args` or via Roots.
## API
### Tools
- **read_text_file**
- Read complete contents of a file as text
- Inputs:
- `path` (string)
- `head` (number, optional): First N lines
- `tail` (number, optional): Last N lines
- Always treats the file as UTF-8 text regardless of extension
- Cannot specify both `head` and `tail` simultaneously
- **read_media_file**
- Read an image or audio file
- Inputs:
- `path` (string)
- Streams the file and returns base64 data with the corresponding MIME type
- **read_multiple_files**
- Read multiple files simultaneously
- Input: `paths` (string[])
- Failed reads won't stop the entire operation
- **write_file**
- Create new file or overwrite existing (exercise caution with this)
- Inputs:
- `path` (string): File location
- `content` (string): File content
- **edit_file**
- Make selective edits using advanced pattern matching and formatting
- Features:
- Line-based and multi-line content matching
- Whitespace normalization with indentation preservation
- Multiple simultaneous edits with correct positioning
- Indentation style detection and preservation
- Git-style diff output with context
- Preview changes with dry run mode
- Inputs:
- `path` (string): File to edit
- `edits` (array): List of edit operations
- `oldText` (string): Text to search for (can be substring)
- `newText` (string): Text to replace with
- `dryRun` (boolean): Preview changes without applying (default: false)
- Returns detailed diff and match information for dry runs, otherwise applies changes
- Best Practice: Always use dryRun first to preview changes before applying them
- **create_directory**
- Create new directory or ensure it exists
- Input: `path` (string)
- Creates parent directories if needed
- Succeeds silently if directory exists
- **list_directory**
- List directory contents with [FILE] or [DIR] prefixes
- Input: `path` (string)
- **list_directory_with_sizes**
- List directory contents with [FILE] or [DIR] prefixes, including file sizes
- Inputs:
- `path` (string): Directory path to list
- `sortBy` (string, optional): Sort entries by "name" or "size" (default: "name")
- Returns detailed listing with file sizes and summary statistics
- Shows total files, directories, and combined size
- **move_file**
- Move or rename files and directories
- Inputs:
- `source` (string)
- `destination` (string)
- Fails if destination exists
- **search_files**
- Recursively search for files/directories that match or do not match patterns
- Inputs:
- `path` (string): Starting directory
- `pattern` (string): Search pattern
- `excludePatterns` (string[]): Exclude any patterns.
- Glob-style pattern matching
- Returns full paths to matches
- **directory_tree**
- Get recursive JSON tree structure of directory contents
- Inputs:
- `path` (string): Starting directory
- `excludePatterns` (string[]): Exclude any patterns. Glob formats are supported.
- Returns:
- JSON array where each entry contains:
- `name` (string): File/directory name
- `type` ('file'|'directory'): Entry type
- `children` (array): Present only for directories
- Empty array for empty directories
- Omitted for files
- Output is formatted with 2-space indentation for readability
- **get_file_info**
- Get detailed file/directory metadata
- Input: `path` (string)
- Returns:
- Size
- Creation time
- Modified time
- Access time
- Type (file/directory)
- Permissions
- **list_allowed_directories**
- List all directories the server is allowed to access
- No input required
- Returns:
- Directories that this server can read/write from
### Tool annotations (MCP hints)
This server sets [MCP ToolAnnotations](https://modelcontextprotocol.io/specification/2025-03-26/server/tools#toolannotations)
on each tool so clients can:
- Distinguish **readonly** tools from writecapable tools.
- Understand which write operations are **idempotent** (safe to retry with the same arguments).
- Highlight operations that may be **destructive** (overwriting or heavily mutating data).
The mapping for filesystem tools is:
| Tool | readOnlyHint | idempotentHint | destructiveHint | Notes |
|-----------------------------|--------------|----------------|-----------------|--------------------------------------------------|
| `read_text_file` | `true` | | | Pure read |
| `read_media_file` | `true` | | | Pure read |
| `read_multiple_files` | `true` | | | Pure read |
| `list_directory` | `true` | | | Pure read |
| `list_directory_with_sizes` | `true` | | | Pure read |
| `directory_tree` | `true` | | | Pure read |
| `search_files` | `true` | | | Pure read |
| `get_file_info` | `true` | | | Pure read |
| `list_allowed_directories` | `true` | | | Pure read |
| `create_directory` | `false` | `true` | `false` | Recreating the same dir is a noop |
| `write_file` | `false` | `true` | `true` | Overwrites existing files |
| `edit_file` | `false` | `false` | `true` | Reapplying edits can fail or doubleapply |
| `move_file` | `false` | `false` | `true` | Deletes source file |
> Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec.
## Usage with Claude Desktop
Add this to your `claude_desktop_config.json`:
Note: you can provide sandboxed directories to the server by mounting them to `/projects`. Adding the `ro` flag will make the directory readonly by the server.
### Docker
Note: all directories must be mounted to `/projects` by default.
```json
{
"mcpServers": {
"filesystem": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--mount", "type=bind,src=/Users/username/Desktop,dst=/projects/Desktop",
"--mount", "type=bind,src=/path/to/other/allowed/dir,dst=/projects/other/allowed/dir,ro",
"--mount", "type=bind,src=/path/to/file.txt,dst=/projects/path/to/file.txt",
"mcp/filesystem",
"/projects"
]
}
}
}
```
### NPX
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/username/Desktop",
"/path/to/other/allowed/dir"
]
}
}
}
```
## Usage with VS Code
For quick installation, click the installation buttons below...
[![Install with NPX in VS Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-filesystem%22%2C%22%24%7BworkspaceFolder%7D%22%5D%7D) [![Install with NPX in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40modelcontextprotocol%2Fserver-filesystem%22%2C%22%24%7BworkspaceFolder%7D%22%5D%7D&quality=insiders)
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Docker-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22--mount%22%2C%22type%3Dbind%2Csrc%3D%24%7BworkspaceFolder%7D%2Cdst%3D%2Fprojects%2Fworkspace%22%2C%22mcp%2Ffilesystem%22%2C%22%2Fprojects%22%5D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Docker-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=filesystem&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22--mount%22%2C%22type%3Dbind%2Csrc%3D%24%7BworkspaceFolder%7D%2Cdst%3D%2Fprojects%2Fworkspace%22%2C%22mcp%2Ffilesystem%22%2C%22%2Fprojects%22%5D%7D&quality=insiders)
For manual installation, you can configure the MCP server using one of these methods:
**Method 1: User Configuration (Recommended)**
Add the configuration to your user-level MCP configuration file. Open the Command Palette (`Ctrl + Shift + P`) and run `MCP: Open User Configuration`. This will open your user `mcp.json` file where you can add the server configuration.
**Method 2: Workspace Configuration**
Alternatively, you can add the configuration to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
> For more details about MCP configuration in VS Code, see the [official VS Code MCP documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers).
You can provide sandboxed directories to the server by mounting them to `/projects`. Adding the `ro` flag will make the directory readonly by the server.
### Docker
Note: all directories must be mounted to `/projects` by default.
```json
{
"servers": {
"filesystem": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--mount", "type=bind,src=${workspaceFolder},dst=/projects/workspace",
"mcp/filesystem",
"/projects"
]
}
}
}
```
### NPX
```json
{
"servers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"${workspaceFolder}"
]
}
}
}
```
## Build
Docker build:
```bash
docker build -t mcp/filesystem -f src/filesystem/Dockerfile .
```
## License
This MCP server is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License. For more details, please see the LICENSE file in the project repository.

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
// We need to test the buildTree function, but it's defined inside the request handler
// So we'll extract the core logic into a testable function
import { minimatch } from 'minimatch';
interface TreeEntry {
name: string;
type: 'file' | 'directory';
children?: TreeEntry[];
}
async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
const entries = await fs.readdir(currentPath, {withFileTypes: true});
const result: TreeEntry[] = [];
for (const entry of entries) {
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
const shouldExclude = excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return minimatch(relativePath, pattern, {dot: true});
}
// For files: match exact name or as part of path
// For directories: match as directory path
return minimatch(relativePath, pattern, {dot: true}) ||
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
});
if (shouldExclude)
continue;
const entryData: TreeEntry = {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file'
};
if (entry.isDirectory()) {
const subPath = path.join(currentPath, entry.name);
entryData.children = await buildTreeForTesting(subPath, rootPath, excludePatterns);
}
result.push(entryData);
}
return result;
}
describe('buildTree exclude patterns', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-'));
// Create test directory structure
await fs.mkdir(path.join(testDir, 'src'));
await fs.mkdir(path.join(testDir, 'node_modules'));
await fs.mkdir(path.join(testDir, '.git'));
await fs.mkdir(path.join(testDir, 'nested', 'node_modules'), { recursive: true });
// Create test files
await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value');
await fs.writeFile(path.join(testDir, '.env.local'), 'LOCAL_SECRET=value');
await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("hello");');
await fs.writeFile(path.join(testDir, 'package.json'), '{}');
await fs.writeFile(path.join(testDir, 'node_modules', 'module.js'), 'module.exports = {};');
await fs.writeFile(path.join(testDir, 'nested', 'node_modules', 'deep.js'), 'module.exports = {};');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should exclude files matching simple patterns', async () => {
// Test the current implementation - this will fail until the bug is fixed
const tree = await buildTreeForTesting(testDir, testDir, ['.env']);
const fileNames = tree.map(entry => entry.name);
expect(fileNames).not.toContain('.env');
expect(fileNames).toContain('.env.local'); // Should not exclude this
expect(fileNames).toContain('src');
expect(fileNames).toContain('package.json');
});
it('should exclude directories matching simple patterns', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
const dirNames = tree.map(entry => entry.name);
expect(dirNames).not.toContain('node_modules');
expect(dirNames).toContain('src');
expect(dirNames).toContain('.git');
});
it('should exclude nested directories with same pattern', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
// Find the nested directory
const nestedDir = tree.find(entry => entry.name === 'nested');
expect(nestedDir).toBeDefined();
expect(nestedDir!.children).toBeDefined();
// The nested/node_modules should also be excluded
const nestedChildren = nestedDir!.children!.map(child => child.name);
expect(nestedChildren).not.toContain('node_modules');
});
it('should handle glob patterns correctly', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['*.env']);
const fileNames = tree.map(entry => entry.name);
expect(fileNames).not.toContain('.env');
expect(fileNames).toContain('.env.local'); // *.env should not match .env.local
expect(fileNames).toContain('src');
});
it('should handle dot files correctly', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['.git']);
const dirNames = tree.map(entry => entry.name);
expect(dirNames).not.toContain('.git');
expect(dirNames).toContain('.env'); // Should not exclude this
});
it('should work with multiple exclude patterns', async () => {
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']);
const entryNames = tree.map(entry => entry.name);
expect(entryNames).not.toContain('node_modules');
expect(entryNames).not.toContain('.env');
expect(entryNames).not.toContain('.git');
expect(entryNames).toContain('src');
expect(entryNames).toContain('package.json');
});
it('should handle empty exclude patterns', async () => {
const tree = await buildTreeForTesting(testDir, testDir, []);
const entryNames = tree.map(entry => entry.name);
// All entries should be included
expect(entryNames).toContain('node_modules');
expect(entryNames).toContain('.env');
expect(entryNames).toContain('.git');
expect(entryNames).toContain('src');
});
});

View File

@@ -0,0 +1,725 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
// Pure utility functions
formatSize,
normalizeLineEndings,
createUnifiedDiff,
// Security & validation functions
validatePath,
setAllowedDirectories,
// File operations
getFileStats,
readFileContent,
writeFileContent,
// Search & filtering functions
searchFilesWithValidation,
// File editing functions
applyFileEdits,
tailFile,
headFile
} from '../lib.js';
// Mock fs module
vi.mock('fs/promises');
const mockFs = fs as any;
describe('Lib Functions', () => {
beforeEach(() => {
vi.clearAllMocks();
// Set up allowed directories for tests
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp', 'C:\\allowed'] : ['/home/user', '/tmp', '/allowed'];
setAllowedDirectories(allowedDirs);
});
afterEach(() => {
vi.restoreAllMocks();
// Clear allowed directories after tests
setAllowedDirectories([]);
});
describe('Pure Utility Functions', () => {
describe('formatSize', () => {
it('formats bytes correctly', () => {
expect(formatSize(0)).toBe('0 B');
expect(formatSize(512)).toBe('512 B');
expect(formatSize(1024)).toBe('1.00 KB');
expect(formatSize(1536)).toBe('1.50 KB');
expect(formatSize(1048576)).toBe('1.00 MB');
expect(formatSize(1073741824)).toBe('1.00 GB');
expect(formatSize(1099511627776)).toBe('1.00 TB');
});
it('handles edge cases', () => {
expect(formatSize(1023)).toBe('1023 B');
expect(formatSize(1025)).toBe('1.00 KB');
expect(formatSize(1048575)).toBe('1024.00 KB');
});
it('handles very large numbers beyond TB', () => {
// The function only supports up to TB, so very large numbers will show as TB
expect(formatSize(1024 * 1024 * 1024 * 1024 * 1024)).toBe('1024.00 TB');
expect(formatSize(Number.MAX_SAFE_INTEGER)).toContain('TB');
});
it('handles negative numbers', () => {
// Negative numbers will result in NaN for the log calculation
expect(formatSize(-1024)).toContain('NaN');
expect(formatSize(-0)).toBe('0 B');
});
it('handles decimal numbers', () => {
expect(formatSize(1536.5)).toBe('1.50 KB');
expect(formatSize(1023.9)).toBe('1023.9 B');
});
it('handles very small positive numbers', () => {
expect(formatSize(1)).toBe('1 B');
expect(formatSize(0.5)).toBe('0.5 B');
expect(formatSize(0.1)).toBe('0.1 B');
});
});
describe('normalizeLineEndings', () => {
it('converts CRLF to LF', () => {
expect(normalizeLineEndings('line1\r\nline2\r\nline3')).toBe('line1\nline2\nline3');
});
it('leaves LF unchanged', () => {
expect(normalizeLineEndings('line1\nline2\nline3')).toBe('line1\nline2\nline3');
});
it('handles mixed line endings', () => {
expect(normalizeLineEndings('line1\r\nline2\nline3\r\n')).toBe('line1\nline2\nline3\n');
});
it('handles empty string', () => {
expect(normalizeLineEndings('')).toBe('');
});
});
describe('createUnifiedDiff', () => {
it('creates diff for simple changes', () => {
const original = 'line1\nline2\nline3';
const modified = 'line1\nmodified line2\nline3';
const diff = createUnifiedDiff(original, modified, 'test.txt');
expect(diff).toContain('--- test.txt');
expect(diff).toContain('+++ test.txt');
expect(diff).toContain('-line2');
expect(diff).toContain('+modified line2');
});
it('handles CRLF normalization', () => {
const original = 'line1\r\nline2\r\n';
const modified = 'line1\nmodified line2\n';
const diff = createUnifiedDiff(original, modified);
expect(diff).toContain('-line2');
expect(diff).toContain('+modified line2');
});
it('handles identical content', () => {
const content = 'line1\nline2\nline3';
const diff = createUnifiedDiff(content, content);
// Should not contain any +/- lines for identical content (excluding header lines)
expect(diff.split('\n').filter((line: string) => line.startsWith('+++') || line.startsWith('---'))).toHaveLength(2);
expect(diff.split('\n').filter((line: string) => line.startsWith('+') && !line.startsWith('+++'))).toHaveLength(0);
expect(diff.split('\n').filter((line: string) => line.startsWith('-') && !line.startsWith('---'))).toHaveLength(0);
});
it('handles empty content', () => {
const diff = createUnifiedDiff('', '');
expect(diff).toContain('--- file');
expect(diff).toContain('+++ file');
});
it('handles default filename parameter', () => {
const diff = createUnifiedDiff('old', 'new');
expect(diff).toContain('--- file');
expect(diff).toContain('+++ file');
});
it('handles custom filename', () => {
const diff = createUnifiedDiff('old', 'new', 'custom.txt');
expect(diff).toContain('--- custom.txt');
expect(diff).toContain('+++ custom.txt');
});
});
});
describe('Security & Validation Functions', () => {
describe('validatePath', () => {
// Use Windows-compatible paths for testing
const allowedDirs = process.platform === 'win32' ? ['C:\\Users\\test', 'C:\\temp'] : ['/home/user', '/tmp'];
beforeEach(() => {
mockFs.realpath.mockImplementation(async (path: any) => path.toString());
});
it('validates allowed paths', async () => {
const testPath = process.platform === 'win32' ? 'C:\\Users\\test\\file.txt' : '/home/user/file.txt';
const result = await validatePath(testPath);
expect(result).toBe(testPath);
});
it('rejects disallowed paths', async () => {
const testPath = process.platform === 'win32' ? 'C:\\Windows\\System32\\file.txt' : '/etc/passwd';
await expect(validatePath(testPath))
.rejects.toThrow('Access denied - path outside allowed directories');
});
it('handles non-existent files by checking parent directory', async () => {
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\newfile.txt' : '/home/user/newfile.txt';
const parentPath = process.platform === 'win32' ? 'C:\\Users\\test' : '/home/user';
// Create an error with the ENOENT code that the implementation checks for
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';
mockFs.realpath
.mockRejectedValueOnce(enoentError)
.mockResolvedValueOnce(parentPath);
const result = await validatePath(newFilePath);
expect(result).toBe(path.resolve(newFilePath));
});
it('rejects when parent directory does not exist', async () => {
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt';
// Create errors with the ENOENT code
const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError1.code = 'ENOENT';
const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError2.code = 'ENOENT';
mockFs.realpath
.mockRejectedValueOnce(enoentError1)
.mockRejectedValueOnce(enoentError2);
await expect(validatePath(newFilePath))
.rejects.toThrow('Parent directory does not exist');
});
it('resolves relative paths against allowed directories instead of process.cwd()', async () => {
const relativePath = 'test-file.txt';
const originalCwd = process.cwd;
// Mock process.cwd to return a directory outside allowed directories
const disallowedCwd = process.platform === 'win32' ? 'C:\\Windows\\System32' : '/root';
(process as any).cwd = vi.fn(() => disallowedCwd);
try {
const result = await validatePath(relativePath);
// Result should be resolved against first allowed directory, not process.cwd()
const expectedPath = process.platform === 'win32'
? path.resolve('C:\\Users\\test', relativePath)
: path.resolve('/home/user', relativePath);
expect(result).toBe(expectedPath);
expect(result).not.toContain(disallowedCwd);
} finally {
// Restore original process.cwd
process.cwd = originalCwd;
}
});
});
});
describe('File Operations', () => {
describe('getFileStats', () => {
it('returns file statistics', async () => {
const mockStats = {
size: 1024,
birthtime: new Date('2023-01-01'),
mtime: new Date('2023-01-02'),
atime: new Date('2023-01-03'),
isDirectory: () => false,
isFile: () => true,
mode: 0o644
};
mockFs.stat.mockResolvedValueOnce(mockStats as any);
const result = await getFileStats('/test/file.txt');
expect(result).toEqual({
size: 1024,
created: new Date('2023-01-01'),
modified: new Date('2023-01-02'),
accessed: new Date('2023-01-03'),
isDirectory: false,
isFile: true,
permissions: '644'
});
});
it('handles directory statistics', async () => {
const mockStats = {
size: 4096,
birthtime: new Date('2023-01-01'),
mtime: new Date('2023-01-02'),
atime: new Date('2023-01-03'),
isDirectory: () => true,
isFile: () => false,
mode: 0o755
};
mockFs.stat.mockResolvedValueOnce(mockStats as any);
const result = await getFileStats('/test/dir');
expect(result.isDirectory).toBe(true);
expect(result.isFile).toBe(false);
expect(result.permissions).toBe('755');
});
});
describe('readFileContent', () => {
it('reads file with default encoding', async () => {
mockFs.readFile.mockResolvedValueOnce('file content');
const result = await readFileContent('/test/file.txt');
expect(result).toBe('file content');
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
});
it('reads file with custom encoding', async () => {
mockFs.readFile.mockResolvedValueOnce('file content');
const result = await readFileContent('/test/file.txt', 'ascii');
expect(result).toBe('file content');
expect(mockFs.readFile).toHaveBeenCalledWith('/test/file.txt', 'ascii');
});
});
describe('writeFileContent', () => {
it('writes file content', async () => {
mockFs.writeFile.mockResolvedValueOnce(undefined);
await writeFileContent('/test/file.txt', 'new content');
expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' });
});
});
});
describe('Search & Filtering Functions', () => {
describe('searchFilesWithValidation', () => {
beforeEach(() => {
mockFs.realpath.mockImplementation(async (path: any) => path.toString());
});
it('excludes files matching exclude patterns', async () => {
const mockEntries = [
{ name: 'test.txt', isDirectory: () => false },
{ name: 'test.log', isDirectory: () => false },
{ name: 'node_modules', isDirectory: () => true }
];
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
const testDir = process.platform === 'win32' ? 'C:\\allowed\\dir' : '/allowed/dir';
const allowedDirs = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
// Mock realpath to return the same path for validation to pass
mockFs.realpath.mockImplementation(async (inputPath: any) => {
const pathStr = inputPath.toString();
// Return the path as-is for validation
return pathStr;
});
const result = await searchFilesWithValidation(
testDir,
'*test*',
allowedDirs,
{ excludePatterns: ['*.log', 'node_modules'] }
);
const expectedResult = process.platform === 'win32' ? 'C:\\allowed\\dir\\test.txt' : '/allowed/dir/test.txt';
expect(result).toEqual([expectedResult]);
});
it('handles validation errors during search', async () => {
const mockEntries = [
{ name: 'test.txt', isDirectory: () => false },
{ name: 'invalid_file.txt', isDirectory: () => false }
];
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
// Mock validatePath to throw error for invalid_file.txt
mockFs.realpath.mockImplementation(async (path: any) => {
if (path.toString().includes('invalid_file.txt')) {
throw new Error('Access denied');
}
return path.toString();
});
const testDir = process.platform === 'win32' ? 'C:\\allowed\\dir' : '/allowed/dir';
const allowedDirs = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
const result = await searchFilesWithValidation(
testDir,
'*test*',
allowedDirs,
{}
);
// Should only return the valid file, skipping the invalid one
const expectedResult = process.platform === 'win32' ? 'C:\\allowed\\dir\\test.txt' : '/allowed/dir/test.txt';
expect(result).toEqual([expectedResult]);
});
it('handles complex exclude patterns with wildcards', async () => {
const mockEntries = [
{ name: 'test.txt', isDirectory: () => false },
{ name: 'test.backup', isDirectory: () => false },
{ name: 'important_test.js', isDirectory: () => false }
];
mockFs.readdir.mockResolvedValueOnce(mockEntries as any);
const testDir = process.platform === 'win32' ? 'C:\\allowed\\dir' : '/allowed/dir';
const allowedDirs = process.platform === 'win32' ? ['C:\\allowed'] : ['/allowed'];
const result = await searchFilesWithValidation(
testDir,
'*test*',
allowedDirs,
{ excludePatterns: ['*.backup'] }
);
const expectedResults = process.platform === 'win32' ? [
'C:\\allowed\\dir\\test.txt',
'C:\\allowed\\dir\\important_test.js'
] : [
'/allowed/dir/test.txt',
'/allowed/dir/important_test.js'
];
expect(result).toEqual(expectedResults);
});
});
});
describe('File Editing Functions', () => {
describe('applyFileEdits', () => {
beforeEach(() => {
mockFs.readFile.mockResolvedValue('line1\nline2\nline3\n');
mockFs.writeFile.mockResolvedValue(undefined);
});
it('applies simple text replacement', async () => {
const edits = [
{ oldText: 'line2', newText: 'modified line2' }
];
mockFs.rename.mockResolvedValueOnce(undefined);
const result = await applyFileEdits('/test/file.txt', edits, false);
expect(result).toContain('modified line2');
// Should write to temporary file then rename
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'line1\nmodified line2\nline3\n',
'utf-8'
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'/test/file.txt'
);
});
it('handles dry run mode', async () => {
const edits = [
{ oldText: 'line2', newText: 'modified line2' }
];
const result = await applyFileEdits('/test/file.txt', edits, true);
expect(result).toContain('modified line2');
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
it('applies multiple edits sequentially', async () => {
const edits = [
{ oldText: 'line1', newText: 'first line' },
{ oldText: 'line3', newText: 'third line' }
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits('/test/file.txt', edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'first line\nline2\nthird line\n',
'utf-8'
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'/test/file.txt'
);
});
it('handles whitespace-flexible matching', async () => {
mockFs.readFile.mockResolvedValue(' line1\n line2\n line3\n');
const edits = [
{ oldText: 'line2', newText: 'modified line2' }
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits('/test/file.txt', edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
' line1\n modified line2\n line3\n',
'utf-8'
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'/test/file.txt'
);
});
it('throws error for non-matching edits', async () => {
const edits = [
{ oldText: 'nonexistent line', newText: 'replacement' }
];
await expect(applyFileEdits('/test/file.txt', edits, false))
.rejects.toThrow('Could not find exact match for edit');
});
it('handles complex multi-line edits with indentation', async () => {
mockFs.readFile.mockResolvedValue('function test() {\n console.log("hello");\n return true;\n}');
const edits = [
{
oldText: ' console.log("hello");\n return true;',
newText: ' console.log("world");\n console.log("test");\n return false;'
}
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits('/test/file.js', edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
'function test() {\n console.log("world");\n console.log("test");\n return false;\n}',
'utf-8'
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
'/test/file.js'
);
});
it('handles edits with different indentation patterns', async () => {
mockFs.readFile.mockResolvedValue(' if (condition) {\n doSomething();\n }');
const edits = [
{
oldText: 'doSomething();',
newText: 'doSomethingElse();\n doAnotherThing();'
}
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits('/test/file.js', edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
' if (condition) {\n doSomethingElse();\n doAnotherThing();\n }',
'utf-8'
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.js\.[a-f0-9]+\.tmp$/),
'/test/file.js'
);
});
it('handles CRLF line endings in file content', async () => {
mockFs.readFile.mockResolvedValue('line1\r\nline2\r\nline3\r\n');
const edits = [
{ oldText: 'line2', newText: 'modified line2' }
];
mockFs.rename.mockResolvedValueOnce(undefined);
await applyFileEdits('/test/file.txt', edits, false);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'line1\nmodified line2\nline3\n',
'utf-8'
);
expect(mockFs.rename).toHaveBeenCalledWith(
expect.stringMatching(/\/test\/file\.txt\.[a-f0-9]+\.tmp$/),
'/test/file.txt'
);
});
});
describe('tailFile', () => {
it('handles empty files', async () => {
mockFs.stat.mockResolvedValue({ size: 0 } as any);
const result = await tailFile('/test/empty.txt', 5);
expect(result).toBe('');
expect(mockFs.open).not.toHaveBeenCalled();
});
it('calls stat to check file size', async () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any);
// Mock file handle with proper typing
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
await tailFile('/test/file.txt', 2);
expect(mockFs.stat).toHaveBeenCalledWith('/test/file.txt');
expect(mockFs.open).toHaveBeenCalledWith('/test/file.txt', 'r');
});
it('handles files with content and returns last lines', async () => {
mockFs.stat.mockResolvedValue({ size: 50 } as any);
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading file content in chunks
mockFileHandle.read
.mockResolvedValueOnce({ bytesRead: 20, buffer: Buffer.from('line3\nline4\nline5\n') })
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await tailFile('/test/file.txt', 2);
expect(mockFileHandle.close).toHaveBeenCalled();
});
it('handles read errors gracefully', async () => {
mockFs.stat.mockResolvedValue({ size: 100 } as any);
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
await tailFile('/test/file.txt', 5);
expect(mockFileHandle.close).toHaveBeenCalled();
});
});
describe('headFile', () => {
it('opens file for reading', async () => {
// Mock file handle with proper typing
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
mockFileHandle.read.mockResolvedValue({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
await headFile('/test/file.txt', 2);
expect(mockFs.open).toHaveBeenCalledWith('/test/file.txt', 'r');
});
it('handles files with content and returns first lines', async () => {
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading file content with newlines
mockFileHandle.read
.mockResolvedValueOnce({ bytesRead: 20, buffer: Buffer.from('line1\nline2\nline3\n') })
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile('/test/file.txt', 2);
expect(mockFileHandle.close).toHaveBeenCalled();
});
it('handles files with leftover content', async () => {
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading file content without final newline
mockFileHandle.read
.mockResolvedValueOnce({ bytesRead: 15, buffer: Buffer.from('line1\nline2\nend') })
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile('/test/file.txt', 5);
expect(mockFileHandle.close).toHaveBeenCalled();
});
it('handles reaching requested line count', async () => {
const mockFileHandle = {
read: vi.fn(),
close: vi.fn()
} as any;
// Simulate reading exactly the requested number of lines
mockFileHandle.read
.mockResolvedValueOnce({ bytesRead: 12, buffer: Buffer.from('line1\nline2\n') })
.mockResolvedValueOnce({ bytesRead: 0 });
mockFileHandle.close.mockResolvedValue(undefined);
mockFs.open.mockResolvedValue(mockFileHandle);
const result = await headFile('/test/file.txt', 2);
expect(mockFileHandle.close).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,382 @@
import { describe, it, expect, afterEach } from 'vitest';
import { normalizePath, expandHome, convertToWindowsPath } from '../path-utils.js';
describe('Path Utilities', () => {
describe('convertToWindowsPath', () => {
it('leaves Unix paths unchanged', () => {
expect(convertToWindowsPath('/usr/local/bin'))
.toBe('/usr/local/bin');
expect(convertToWindowsPath('/home/user/some path'))
.toBe('/home/user/some path');
});
it('never converts WSL paths (they work correctly in WSL with Node.js fs)', () => {
// WSL paths should NEVER be converted, regardless of platform
// They are valid Linux paths that work with Node.js fs operations inside WSL
expect(convertToWindowsPath('/mnt/c/NS/MyKindleContent'))
.toBe('/mnt/c/NS/MyKindleContent');
expect(convertToWindowsPath('/mnt/d/Documents'))
.toBe('/mnt/d/Documents');
});
it('converts Unix-style Windows paths only on Windows platform', () => {
// On Windows, /c/ style paths should be converted
if (process.platform === 'win32') {
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
} else {
// On Linux, leave them unchanged
expect(convertToWindowsPath('/c/NS/MyKindleContent'))
.toBe('/c/NS/MyKindleContent');
}
});
it('leaves Windows paths unchanged but ensures backslashes', () => {
expect(convertToWindowsPath('C:\\NS\\MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
expect(convertToWindowsPath('C:/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
});
it('handles Windows paths with spaces', () => {
expect(convertToWindowsPath('C:\\Program Files\\Some App'))
.toBe('C:\\Program Files\\Some App');
expect(convertToWindowsPath('C:/Program Files/Some App'))
.toBe('C:\\Program Files\\Some App');
});
it('handles drive letter paths based on platform', () => {
// WSL paths should never be converted
expect(convertToWindowsPath('/mnt/d/some/path'))
.toBe('/mnt/d/some/path');
if (process.platform === 'win32') {
// On Windows, Unix-style paths like /d/ should be converted
expect(convertToWindowsPath('/d/some/path'))
.toBe('D:\\some\\path');
} else {
// On Linux, /d/ is just a regular Unix path
expect(convertToWindowsPath('/d/some/path'))
.toBe('/d/some/path');
}
});
});
describe('normalizePath', () => {
it('preserves Unix paths', () => {
expect(normalizePath('/usr/local/bin'))
.toBe('/usr/local/bin');
expect(normalizePath('/home/user/some path'))
.toBe('/home/user/some path');
expect(normalizePath('"/usr/local/some app/"'))
.toBe('/usr/local/some app');
expect(normalizePath('/usr/local//bin/app///'))
.toBe('/usr/local/bin/app');
expect(normalizePath('/'))
.toBe('/');
expect(normalizePath('///'))
.toBe('/');
});
it('removes surrounding quotes', () => {
expect(normalizePath('"C:\\NS\\My Kindle Content"'))
.toBe('C:\\NS\\My Kindle Content');
});
it('normalizes backslashes', () => {
expect(normalizePath('C:\\\\NS\\\\MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
});
it('converts forward slashes to backslashes on Windows', () => {
expect(normalizePath('C:/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
});
it('always preserves WSL paths (they work correctly in WSL)', () => {
// WSL paths should ALWAYS be preserved, regardless of platform
// This is the fix for issue #2795
expect(normalizePath('/mnt/c/NS/MyKindleContent'))
.toBe('/mnt/c/NS/MyKindleContent');
expect(normalizePath('/mnt/d/Documents'))
.toBe('/mnt/d/Documents');
});
it('handles Unix-style Windows paths', () => {
// On Windows, /c/ paths should be converted
if (process.platform === 'win32') {
expect(normalizePath('/c/NS/MyKindleContent'))
.toBe('C:\\NS\\MyKindleContent');
} else if (process.platform === 'linux') {
// On Linux, /c/ is just a regular Unix path
expect(normalizePath('/c/NS/MyKindleContent'))
.toBe('/c/NS/MyKindleContent');
}
});
it('handles paths with spaces and mixed slashes', () => {
expect(normalizePath('C:/NS/My Kindle Content'))
.toBe('C:\\NS\\My Kindle Content');
// WSL paths should always be preserved
expect(normalizePath('/mnt/c/NS/My Kindle Content'))
.toBe('/mnt/c/NS/My Kindle Content');
expect(normalizePath('C:\\Program Files (x86)\\App Name'))
.toBe('C:\\Program Files (x86)\\App Name');
expect(normalizePath('"C:\\Program Files\\App Name"'))
.toBe('C:\\Program Files\\App Name');
expect(normalizePath(' C:\\Program Files\\App Name '))
.toBe('C:\\Program Files\\App Name');
});
it('preserves spaces in all path formats', () => {
// WSL paths should always be preserved
expect(normalizePath('/mnt/c/Program Files/App Name'))
.toBe('/mnt/c/Program Files/App Name');
if (process.platform === 'win32') {
// On Windows, Unix-style paths like /c/ should be converted
expect(normalizePath('/c/Program Files/App Name'))
.toBe('C:\\Program Files\\App Name');
} else {
// On Linux, /c/ is just a regular Unix path
expect(normalizePath('/c/Program Files/App Name'))
.toBe('/c/Program Files/App Name');
}
expect(normalizePath('C:/Program Files/App Name'))
.toBe('C:\\Program Files\\App Name');
});
it('handles special characters in paths', () => {
// Test ampersand in path
expect(normalizePath('C:\\NS\\Sub&Folder'))
.toBe('C:\\NS\\Sub&Folder');
expect(normalizePath('C:/NS/Sub&Folder'))
.toBe('C:\\NS\\Sub&Folder');
// WSL paths should always be preserved
expect(normalizePath('/mnt/c/NS/Sub&Folder'))
.toBe('/mnt/c/NS/Sub&Folder');
// Test tilde in path (short names in Windows)
expect(normalizePath('C:\\NS\\MYKIND~1'))
.toBe('C:\\NS\\MYKIND~1');
expect(normalizePath('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1'))
.toBe('/Users/NEMANS~1/FOLDER~2/SUBFO~1/Public/P12PST~1');
// Test other special characters
expect(normalizePath('C:\\Path with #hash'))
.toBe('C:\\Path with #hash');
expect(normalizePath('C:\\Path with (parentheses)'))
.toBe('C:\\Path with (parentheses)');
expect(normalizePath('C:\\Path with [brackets]'))
.toBe('C:\\Path with [brackets]');
expect(normalizePath('C:\\Path with @at+plus$dollar%percent'))
.toBe('C:\\Path with @at+plus$dollar%percent');
});
it('capitalizes lowercase drive letters for Windows paths', () => {
expect(normalizePath('c:/windows/system32'))
.toBe('C:\\windows\\system32');
// WSL paths should always be preserved
expect(normalizePath('/mnt/d/my/folder'))
.toBe('/mnt/d/my/folder');
if (process.platform === 'win32') {
// On Windows, Unix-style paths should be converted and capitalized
expect(normalizePath('/e/another/folder'))
.toBe('E:\\another\\folder');
} else {
// On Linux, /e/ is just a regular Unix path
expect(normalizePath('/e/another/folder'))
.toBe('/e/another/folder');
}
});
it('handles UNC paths correctly', () => {
// UNC paths should preserve the leading double backslash
const uncPath = '\\\\SERVER\\share\\folder';
expect(normalizePath(uncPath)).toBe('\\\\SERVER\\share\\folder');
// Test UNC path with double backslashes that need normalization
const uncPathWithDoubles = '\\\\\\\\SERVER\\\\share\\\\folder';
expect(normalizePath(uncPathWithDoubles)).toBe('\\\\SERVER\\share\\folder');
});
it('returns normalized non-Windows/WSL/Unix-style Windows paths as is after basic normalization', () => {
// A path that looks somewhat absolute but isn't a drive or recognized Unix root for Windows conversion
// These paths should be preserved as-is (not converted to Windows C:\ format or WSL format)
const otherAbsolutePath = '\\someserver\\share\\file';
expect(normalizePath(otherAbsolutePath)).toBe(otherAbsolutePath);
});
});
describe('expandHome', () => {
it('expands ~ to home directory', () => {
const result = expandHome('~/test');
expect(result).toContain('test');
expect(result).not.toContain('~');
});
it('expands bare ~ to home directory', () => {
const result = expandHome('~');
expect(result).not.toContain('~');
expect(result.length).toBeGreaterThan(0);
});
it('leaves other paths unchanged', () => {
expect(expandHome('C:/test')).toBe('C:/test');
});
});
describe('WSL path handling (issue #2795 fix)', () => {
// Save original platform
const originalPlatform = process.platform;
afterEach(() => {
// Restore platform after each test
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true
});
});
it('should NEVER convert WSL paths - they work correctly in WSL with Node.js fs', () => {
// The key insight: When running `wsl npx ...`, Node.js runs INSIDE WSL (process.platform === 'linux')
// and /mnt/c/ paths work correctly with Node.js fs operations in that environment.
// Converting them to C:\ format breaks fs operations because Windows paths don't work inside WSL.
// Mock Linux platform (inside WSL)
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
// WSL paths should NOT be converted, even inside WSL
expect(normalizePath('/mnt/c/Users/username/folder'))
.toBe('/mnt/c/Users/username/folder');
expect(normalizePath('/mnt/d/Documents/project'))
.toBe('/mnt/d/Documents/project');
});
it('should also preserve WSL paths when running on Windows', () => {
// Mock Windows platform
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
// WSL paths should still be preserved (though they wouldn't be accessible from Windows Node.js)
expect(normalizePath('/mnt/c/Users/username/folder'))
.toBe('/mnt/c/Users/username/folder');
expect(normalizePath('/mnt/d/Documents/project'))
.toBe('/mnt/d/Documents/project');
});
it('should convert Unix-style Windows paths (/c/) only when running on Windows (win32)', () => {
// Mock process.platform to be 'win32' (Windows)
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
// Unix-style Windows paths like /c/ should be converted on Windows
expect(normalizePath('/c/Users/username/folder'))
.toBe('C:\\Users\\username\\folder');
expect(normalizePath('/d/Documents/project'))
.toBe('D:\\Documents\\project');
});
it('should NOT convert Unix-style paths (/c/) when running inside WSL (linux)', () => {
// Mock process.platform to be 'linux' (WSL/Linux)
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
// When on Linux, /c/ is just a regular Unix directory, not a drive letter
expect(normalizePath('/c/some/path'))
.toBe('/c/some/path');
expect(normalizePath('/d/another/path'))
.toBe('/d/another/path');
});
it('should preserve regular Unix paths on all platforms', () => {
// Test on Linux
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
expect(normalizePath('/home/user/documents'))
.toBe('/home/user/documents');
expect(normalizePath('/var/log/app'))
.toBe('/var/log/app');
// Test on Windows (though these paths wouldn't work on Windows)
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
expect(normalizePath('/home/user/documents'))
.toBe('/home/user/documents');
expect(normalizePath('/var/log/app'))
.toBe('/var/log/app');
});
it('reproduces exact scenario from issue #2795', () => {
// Simulate running inside WSL: wsl npx @modelcontextprotocol/server-filesystem /mnt/c/Users/username/folder
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true
});
// This is the exact path from the issue
const inputPath = '/mnt/c/Users/username/folder';
const result = normalizePath(inputPath);
// Should NOT convert to C:\Users\username\folder
expect(result).toBe('/mnt/c/Users/username/folder');
expect(result).not.toContain('C:');
expect(result).not.toContain('\\');
});
it('normalizes bare Windows drive letters to the drive root on Windows', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
expect(normalizePath('C:')).toBe('C:\\');
expect(normalizePath('d:')).toBe('D:\\');
});
it('should handle relative path slash conversion based on platform', () => {
// This test verifies platform-specific behavior naturally without mocking
// On Windows: forward slashes converted to backslashes
// On Linux/Unix: forward slashes preserved
const relativePath = 'some/relative/path';
const result = normalizePath(relativePath);
if (originalPlatform === 'win32') {
expect(result).toBe('some\\relative\\path');
} else {
expect(result).toBe('some/relative/path');
}
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getValidRootDirectories } from '../roots-utils.js';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import type { Root } from '@modelcontextprotocol/sdk/types.js';
describe('getValidRootDirectories', () => {
let testDir1: string;
let testDir2: string;
let testDir3: string;
let testFile: string;
beforeEach(() => {
// Create test directories
testDir1 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test1-')));
testDir2 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test2-')));
testDir3 = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-roots-test3-')));
// Create a test file (not a directory)
testFile = join(testDir1, 'test-file.txt');
writeFileSync(testFile, 'test content');
});
afterEach(() => {
// Cleanup
rmSync(testDir1, { recursive: true, force: true });
rmSync(testDir2, { recursive: true, force: true });
rmSync(testDir3, { recursive: true, force: true });
});
describe('valid directory processing', () => {
it('should process all URI formats and edge cases', async () => {
const roots = [
{ uri: `file://${testDir1}`, name: 'File URI' },
{ uri: testDir2, name: 'Plain path' },
{ uri: testDir3 } // Plain path without name property
];
const result = await getValidRootDirectories(roots);
expect(result).toContain(testDir1);
expect(result).toContain(testDir2);
expect(result).toContain(testDir3);
expect(result).toHaveLength(3);
});
it('should normalize complex paths', async () => {
const subDir = join(testDir1, 'subdir');
mkdirSync(subDir);
const roots = [
{ uri: `file://${testDir1}/./subdir/../subdir`, name: 'Complex Path' }
];
const result = await getValidRootDirectories(roots);
expect(result).toHaveLength(1);
expect(result[0]).toBe(subDir);
});
});
describe('error handling', () => {
it('should handle various error types', async () => {
const nonExistentDir = join(tmpdir(), 'non-existent-directory-12345');
const invalidPath = '\0invalid\0path'; // Null bytes cause different error types
const roots = [
{ uri: `file://${testDir1}`, name: 'Valid Dir' },
{ uri: `file://${nonExistentDir}`, name: 'Non-existent Dir' },
{ uri: `file://${testFile}`, name: 'File Not Dir' },
{ uri: `file://${invalidPath}`, name: 'Invalid Path' }
];
const result = await getValidRootDirectories(roots);
expect(result).toContain(testDir1);
expect(result).not.toContain(nonExistentDir);
expect(result).not.toContain(testFile);
expect(result).not.toContain(invalidPath);
expect(result).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn } from 'child_process';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';
const SERVER_PATH = path.join(__dirname, '..', 'dist', 'index.js');
/**
* Spawns the filesystem server with given arguments and returns exit info
*/
async function spawnServer(args: string[], timeoutMs = 2000): Promise<{ exitCode: number | null; stderr: string }> {
return new Promise((resolve) => {
const proc = spawn('node', [SERVER_PATH, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
});
const timeout = setTimeout(() => {
proc.kill('SIGTERM');
}, timeoutMs);
proc.on('close', (code) => {
clearTimeout(timeout);
resolve({ exitCode: code, stderr });
});
proc.on('error', (err) => {
clearTimeout(timeout);
resolve({ exitCode: 1, stderr: err.message });
});
});
}
describe('Startup Directory Validation', () => {
let testDir: string;
let accessibleDir: string;
let accessibleDir2: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-startup-test-'));
accessibleDir = path.join(testDir, 'accessible');
accessibleDir2 = path.join(testDir, 'accessible2');
await fs.mkdir(accessibleDir, { recursive: true });
await fs.mkdir(accessibleDir2, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should start successfully with all accessible directories', async () => {
const result = await spawnServer([accessibleDir, accessibleDir2]);
// Server starts and runs (we kill it after timeout, so exit code is null or from SIGTERM)
expect(result.stderr).toContain('Secure MCP Filesystem Server running on stdio');
expect(result.stderr).not.toContain('Error:');
});
it('should skip inaccessible directory and continue with accessible one', async () => {
const nonExistentDir = path.join(testDir, 'non-existent-dir-12345');
const result = await spawnServer([nonExistentDir, accessibleDir]);
// Should warn about inaccessible directory
expect(result.stderr).toContain('Warning: Cannot access directory');
expect(result.stderr).toContain(nonExistentDir);
// Should still start successfully
expect(result.stderr).toContain('Secure MCP Filesystem Server running on stdio');
});
it('should exit with error when ALL directories are inaccessible', async () => {
const nonExistent1 = path.join(testDir, 'non-existent-1');
const nonExistent2 = path.join(testDir, 'non-existent-2');
const result = await spawnServer([nonExistent1, nonExistent2]);
// Should exit with error
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Error: None of the specified directories are accessible');
});
it('should warn when path is not a directory', async () => {
const filePath = path.join(testDir, 'not-a-directory.txt');
await fs.writeFile(filePath, 'content');
const result = await spawnServer([filePath, accessibleDir]);
// Should warn about non-directory
expect(result.stderr).toContain('Warning:');
expect(result.stderr).toContain('not a directory');
// Should still start with the valid directory
expect(result.stderr).toContain('Secure MCP Filesystem Server running on stdio');
});
});

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { spawn } from 'child_process';
/**
* Integration tests to verify that tool handlers return structuredContent
* that matches the declared outputSchema.
*
* These tests address issues #3110, #3106, #3093 where tools were returning
* structuredContent: { content: [contentBlock] } (array) instead of
* structuredContent: { content: string } as declared in outputSchema.
*/
describe('structuredContent schema compliance', () => {
let client: Client;
let transport: StdioClientTransport;
let testDir: string;
beforeEach(async () => {
// Create a temp directory for testing
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-fs-test-'));
// Create test files
await fs.writeFile(path.join(testDir, 'test.txt'), 'test content');
await fs.mkdir(path.join(testDir, 'subdir'));
await fs.writeFile(path.join(testDir, 'subdir', 'nested.txt'), 'nested content');
// Start the MCP server
const serverPath = path.resolve(__dirname, '../dist/index.js');
transport = new StdioClientTransport({
command: 'node',
args: [serverPath, testDir],
});
client = new Client({
name: 'test-client',
version: '1.0.0',
}, {
capabilities: {}
});
await client.connect(transport);
});
afterEach(async () => {
await client?.close();
await fs.rm(testDir, { recursive: true, force: true });
});
describe('directory_tree', () => {
it('should return structuredContent.content as a string, not an array', async () => {
const result = await client.callTool({
name: 'directory_tree',
arguments: { path: testDir }
});
// The result should have structuredContent
expect(result.structuredContent).toBeDefined();
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
// It should NOT be an array
expect(Array.isArray(structuredContent.content)).toBe(false);
// The content should be valid JSON representing the tree
const treeData = JSON.parse(structuredContent.content as string);
expect(Array.isArray(treeData)).toBe(true);
});
});
describe('list_directory_with_sizes', () => {
it('should return structuredContent.content as a string, not an array', async () => {
const result = await client.callTool({
name: 'list_directory_with_sizes',
arguments: { path: testDir }
});
// The result should have structuredContent
expect(result.structuredContent).toBeDefined();
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
// It should NOT be an array
expect(Array.isArray(structuredContent.content)).toBe(false);
// The content should contain directory listing info
expect(structuredContent.content).toContain('[FILE]');
});
});
describe('move_file', () => {
it('should return structuredContent.content as a string, not an array', async () => {
const sourcePath = path.join(testDir, 'test.txt');
const destPath = path.join(testDir, 'moved.txt');
const result = await client.callTool({
name: 'move_file',
arguments: {
source: sourcePath,
destination: destPath
}
});
// The result should have structuredContent
expect(result.structuredContent).toBeDefined();
// structuredContent.content should be a string (matching outputSchema: { content: z.string() })
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
// It should NOT be an array
expect(Array.isArray(structuredContent.content)).toBe(false);
// The content should contain success message
expect(structuredContent.content).toContain('Successfully moved');
});
});
describe('list_directory (control - already working)', () => {
it('should return structuredContent.content as a string', async () => {
const result = await client.callTool({
name: 'list_directory',
arguments: { path: testDir }
});
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
expect(Array.isArray(structuredContent.content)).toBe(false);
});
});
describe('search_files (control - already working)', () => {
it('should return structuredContent.content as a string', async () => {
const result = await client.callTool({
name: 'search_files',
arguments: {
path: testDir,
pattern: '*.txt'
}
});
expect(result.structuredContent).toBeDefined();
const structuredContent = result.structuredContent as { content: unknown };
expect(typeof structuredContent.content).toBe('string');
expect(Array.isArray(structuredContent.content)).toBe(false);
});
});
});

View File

@@ -0,0 +1,767 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolResult,
RootsListChangedNotificationSchema,
type Root,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "fs/promises";
import { createReadStream } from "fs";
import path from "path";
import { z } from "zod";
import { minimatch } from "minimatch";
import { normalizePath, expandHome } from './path-utils.js';
import { getValidRootDirectories } from './roots-utils.js';
import {
// Function imports
formatSize,
validatePath,
getFileStats,
readFileContent,
writeFileContent,
searchFilesWithValidation,
applyFileEdits,
tailFile,
headFile,
setAllowedDirectories,
} from './lib.js';
// Command line argument parsing
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]");
console.error("Note: Allowed directories can be provided via:");
console.error(" 1. Command-line arguments (shown above)");
console.error(" 2. MCP roots protocol (if client supports it)");
console.error("At least one directory must be provided by EITHER method for the server to operate.");
}
// Store allowed directories in normalized and resolved form
// We store BOTH the original path AND the resolved path to handle symlinks correctly
// This fixes the macOS /tmp -> /private/tmp symlink issue where users specify /tmp
// but the resolved path is /private/tmp
let allowedDirectories = (await Promise.all(
args.map(async (dir) => {
const expanded = expandHome(dir);
const absolute = path.resolve(expanded);
const normalizedOriginal = normalizePath(absolute);
try {
// Security: Resolve symlinks in allowed directories during startup
// This ensures we know the real paths and can validate against them later
const resolved = await fs.realpath(absolute);
const normalizedResolved = normalizePath(resolved);
// Return both original and resolved paths if they differ
// This allows matching against either /tmp or /private/tmp on macOS
if (normalizedOriginal !== normalizedResolved) {
return [normalizedOriginal, normalizedResolved];
}
return [normalizedResolved];
} catch (error) {
// If we can't resolve (doesn't exist), use the normalized absolute path
// This allows configuring allowed dirs that will be created later
return [normalizedOriginal];
}
})
)).flat();
// Filter to only accessible directories, warn about inaccessible ones
const accessibleDirectories: string[] = [];
for (const dir of allowedDirectories) {
try {
const stats = await fs.stat(dir);
if (stats.isDirectory()) {
accessibleDirectories.push(dir);
} else {
console.error(`Warning: ${dir} is not a directory, skipping`);
}
} catch (error) {
console.error(`Warning: Cannot access directory ${dir}, skipping`);
}
}
// Exit only if ALL paths are inaccessible (and some were specified)
if (accessibleDirectories.length === 0 && allowedDirectories.length > 0) {
console.error("Error: None of the specified directories are accessible");
process.exit(1);
}
allowedDirectories = accessibleDirectories;
// Initialize the global allowedDirectories in lib.ts
setAllowedDirectories(allowedDirectories);
// Schema definitions
const ReadTextFileArgsSchema = z.object({
path: z.string(),
tail: z.number().optional().describe('If provided, returns only the last N lines of the file'),
head: z.number().optional().describe('If provided, returns only the first N lines of the file')
});
const ReadMediaFileArgsSchema = z.object({
path: z.string()
});
const ReadMultipleFilesArgsSchema = z.object({
paths: z
.array(z.string())
.min(1, "At least one file path must be provided")
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories."),
});
const WriteFileArgsSchema = z.object({
path: z.string(),
content: z.string(),
});
const EditOperation = z.object({
oldText: z.string().describe('Text to search for - must match exactly'),
newText: z.string().describe('Text to replace with')
});
const EditFileArgsSchema = z.object({
path: z.string(),
edits: z.array(EditOperation),
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
});
const CreateDirectoryArgsSchema = z.object({
path: z.string(),
});
const ListDirectoryArgsSchema = z.object({
path: z.string(),
});
const ListDirectoryWithSizesArgsSchema = z.object({
path: z.string(),
sortBy: z.enum(['name', 'size']).optional().default('name').describe('Sort entries by name or size'),
});
const DirectoryTreeArgsSchema = z.object({
path: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
});
const MoveFileArgsSchema = z.object({
source: z.string(),
destination: z.string(),
});
const SearchFilesArgsSchema = z.object({
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
});
const GetFileInfoArgsSchema = z.object({
path: z.string(),
});
// Server setup
const server = new McpServer(
{
name: "secure-filesystem-server",
version: "0.2.0",
}
);
// Reads a file as a stream of buffers, concatenates them, and then encodes
// the result to a Base64 string. This is a memory-efficient way to handle
// binary data from a stream before the final encoding.
async function readFileAsBase64Stream(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const stream = createReadStream(filePath);
const chunks: Buffer[] = [];
stream.on('data', (chunk) => {
chunks.push(chunk as Buffer);
});
stream.on('end', () => {
const finalBuffer = Buffer.concat(chunks);
resolve(finalBuffer.toString('base64'));
});
stream.on('error', (err) => reject(err));
});
}
// Tool registrations
// read_file (deprecated) and read_text_file
const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>) => {
const validPath = await validatePath(args.path);
if (args.head && args.tail) {
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
let content: string;
if (args.tail) {
content = await tailFile(validPath, args.tail);
} else if (args.head) {
content = await headFile(validPath, args.head);
} else {
content = await readFileContent(validPath);
}
return {
content: [{ type: "text" as const, text: content }],
structuredContent: { content }
};
};
server.registerTool(
"read_file",
{
title: "Read File (Deprecated)",
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
inputSchema: ReadTextFileArgsSchema.shape,
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
readTextFileHandler
);
server.registerTool(
"read_text_file",
{
title: "Read Text File",
description:
"Read the complete contents of a file from the file system as text. " +
"Handles various text encodings and provides detailed error messages " +
"if the file cannot be read. Use this tool when you need to examine " +
"the contents of a single file. Use the 'head' parameter to read only " +
"the first N lines of a file, or the 'tail' parameter to read only " +
"the last N lines of a file. Operates on the file as text regardless of extension. " +
"Only works within allowed directories.",
inputSchema: {
path: z.string(),
tail: z.number().optional().describe("If provided, returns only the last N lines of the file"),
head: z.number().optional().describe("If provided, returns only the first N lines of the file")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
readTextFileHandler
);
server.registerTool(
"read_media_file",
{
title: "Read Media File",
description:
"Read an image or audio file. Returns the base64 encoded data and MIME type. " +
"Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: {
content: z.array(z.object({
type: z.enum(["image", "audio", "blob"]),
data: z.string(),
mimeType: z.string()
}))
},
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ReadMediaFileArgsSchema>) => {
const validPath = await validatePath(args.path);
const extension = path.extname(validPath).toLowerCase();
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".flac": "audio/flac",
};
const mimeType = mimeTypes[extension] || "application/octet-stream";
const data = await readFileAsBase64Stream(validPath);
const type = mimeType.startsWith("image/")
? "image"
: mimeType.startsWith("audio/")
? "audio"
// Fallback for other binary types, not officially supported by the spec but has been used for some time
: "blob";
const contentItem = { type: type as 'image' | 'audio' | 'blob', data, mimeType };
return {
content: [contentItem],
structuredContent: { content: [contentItem] }
} as unknown as CallToolResult;
}
);
server.registerTool(
"read_multiple_files",
{
title: "Read Multiple Files",
description:
"Read the contents of multiple files simultaneously. This is more " +
"efficient than reading files one by one when you need to analyze " +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
"the entire operation. Only works within allowed directories.",
inputSchema: {
paths: z.array(z.string())
.min(1)
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
const results = await Promise.all(
args.paths.map(async (filePath: string) => {
try {
const validPath = await validatePath(filePath);
const content = await readFileContent(validPath);
return `${filePath}:\n${content}\n`;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return `${filePath}: Error - ${errorMessage}`;
}
}),
);
const text = results.join("\n---\n");
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
server.registerTool(
"write_file",
{
title: "Write File",
description:
"Create a new file or completely overwrite an existing file with new content. " +
"Use with caution as it will overwrite existing files without warning. " +
"Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: {
path: z.string(),
content: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: true }
},
async (args: z.infer<typeof WriteFileArgsSchema>) => {
const validPath = await validatePath(args.path);
await writeFileContent(validPath, args.content);
const text = `Successfully wrote to ${args.path}`;
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
server.registerTool(
"edit_file",
{
title: "Edit File",
description:
"Make line-based edits to a text file. Each edit replaces exact line sequences " +
"with new content. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.",
inputSchema: {
path: z.string(),
edits: z.array(z.object({
oldText: z.string().describe("Text to search for - must match exactly"),
newText: z.string().describe("Text to replace with")
})),
dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
},
async (args: z.infer<typeof EditFileArgsSchema>) => {
const validPath = await validatePath(args.path);
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
return {
content: [{ type: "text" as const, text: result }],
structuredContent: { content: result }
};
}
);
server.registerTool(
"create_directory",
{
title: "Create Directory",
description:
"Create a new directory or ensure a directory exists. Can create multiple " +
"nested directories in one operation. If the directory already exists, " +
"this operation will succeed silently. Perfect for setting up directory " +
"structures for projects or ensuring required paths exist. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false }
},
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
await fs.mkdir(validPath, { recursive: true });
const text = `Successfully created directory ${args.path}`;
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
server.registerTool(
"list_directory",
{
title: "List Directory",
description:
"Get a detailed listing of all files and directories in a specified path. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is essential for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
.join("\n");
return {
content: [{ type: "text" as const, text: formatted }],
structuredContent: { content: formatted }
};
}
);
server.registerTool(
"list_directory_with_sizes",
{
title: "List Directory with Sizes",
description:
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is useful for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: {
path: z.string(),
sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size")
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true });
// Get detailed information for each entry
const detailedEntries = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(validPath, entry.name);
try {
const stats = await fs.stat(entryPath);
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: stats.size,
mtime: stats.mtime
};
} catch (error) {
return {
name: entry.name,
isDirectory: entry.isDirectory(),
size: 0,
mtime: new Date(0)
};
}
})
);
// Sort entries based on sortBy parameter
const sortedEntries = [...detailedEntries].sort((a, b) => {
if (args.sortBy === 'size') {
return b.size - a.size; // Descending by size
}
// Default sort by name
return a.name.localeCompare(b.name);
});
// Format the output
const formattedEntries = sortedEntries.map(entry =>
`${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name.padEnd(30)} ${
entry.isDirectory ? "" : formatSize(entry.size).padStart(10)
}`
);
// Add summary
const totalFiles = detailedEntries.filter(e => !e.isDirectory).length;
const totalDirs = detailedEntries.filter(e => e.isDirectory).length;
const totalSize = detailedEntries.reduce((sum, entry) => sum + (entry.isDirectory ? 0 : entry.size), 0);
const summary = [
"",
`Total: ${totalFiles} files, ${totalDirs} directories`,
`Combined size: ${formatSize(totalSize)}`
];
const text = [...formattedEntries, ...summary].join("\n");
const contentBlock = { type: "text" as const, text };
return {
content: [contentBlock],
structuredContent: { content: text }
};
}
);
server.registerTool(
"directory_tree",
{
title: "Directory Tree",
description:
"Get a recursive tree view of files and directories as a JSON structure. " +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
"Files have no children array, while directories always have a children array (which may be empty). " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: {
path: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
interface TreeEntry {
name: string;
type: 'file' | 'directory';
children?: TreeEntry[];
}
const rootPath = args.path;
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
const validPath = await validatePath(currentPath);
const entries = await fs.readdir(validPath, { withFileTypes: true });
const result: TreeEntry[] = [];
for (const entry of entries) {
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
const shouldExclude = excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return minimatch(relativePath, pattern, { dot: true });
}
// For files: match exact name or as part of path
// For directories: match as directory path
return minimatch(relativePath, pattern, { dot: true }) ||
minimatch(relativePath, `**/${pattern}`, { dot: true }) ||
minimatch(relativePath, `**/${pattern}/**`, { dot: true });
});
if (shouldExclude)
continue;
const entryData: TreeEntry = {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file'
};
if (entry.isDirectory()) {
const subPath = path.join(currentPath, entry.name);
entryData.children = await buildTree(subPath, excludePatterns);
}
result.push(entryData);
}
return result;
}
const treeData = await buildTree(rootPath, args.excludePatterns);
const text = JSON.stringify(treeData, null, 2);
const contentBlock = { type: "text" as const, text };
return {
content: [contentBlock],
structuredContent: { content: text }
};
}
);
server.registerTool(
"move_file",
{
title: "Move File",
description:
"Move or rename files and directories. Can move files between directories " +
"and rename them in a single operation. If the destination exists, the " +
"operation will fail. Works across different directories and can be used " +
"for simple renaming within the same directory. Both source and destination must be within allowed directories.",
inputSchema: {
source: z.string(),
destination: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true }
},
async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination);
await fs.rename(validSourcePath, validDestPath);
const text = `Successfully moved ${args.source} to ${args.destination}`;
const contentBlock = { type: "text" as const, text };
return {
content: [contentBlock],
structuredContent: { content: text }
};
}
);
server.registerTool(
"search_files",
{
title: "Search Files",
description:
"Recursively search for files and directories matching a pattern. " +
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories.",
inputSchema: {
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
const validPath = await validatePath(args.path);
const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns });
const text = results.length > 0 ? results.join("\n") : "No matches found";
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
server.registerTool(
"get_file_info",
{
title: "Get File Info",
description:
"Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
const validPath = await validatePath(args.path);
const info = await getFileStats(validPath);
const text = Object.entries(info)
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
server.registerTool(
"list_allowed_directories",
{
title: "List Allowed Directories",
description:
"Returns the list of directories that this server is allowed to access. " +
"Subdirectories within these allowed directories are also accessible. " +
"Use this to understand which directories and their nested paths are available " +
"before trying to access files.",
inputSchema: {},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async () => {
const text = `Allowed directories:\n${allowedDirectories.join('\n')}`;
return {
content: [{ type: "text" as const, text }],
structuredContent: { content: text }
};
}
);
// Updates allowed directories based on MCP client roots
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
const validatedRootDirs = await getValidRootDirectories(requestedRoots);
if (validatedRootDirs.length > 0) {
allowedDirectories = [...validatedRootDirs];
setAllowedDirectories(allowedDirectories); // Update the global state in lib.ts
console.error(`Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`);
} else {
console.error("No valid root directories provided by client");
}
}
// Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots.
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
try {
// Request the updated roots list from the client
const response = await server.server.listRoots();
if (response && 'roots' in response) {
await updateAllowedDirectoriesFromRoots(response.roots);
}
} catch (error) {
console.error("Failed to request roots from client:", error instanceof Error ? error.message : String(error));
}
});
// Handles post-initialization setup, specifically checking for and fetching MCP roots.
server.server.oninitialized = async () => {
const clientCapabilities = server.server.getClientCapabilities();
if (clientCapabilities?.roots) {
try {
const response = await server.server.listRoots();
if (response && 'roots' in response) {
await updateAllowedDirectoriesFromRoots(response.roots);
} else {
console.error("Client returned no roots set, keeping current settings");
}
} catch (error) {
console.error("Failed to request initial roots from client:", error instanceof Error ? error.message : String(error));
}
} else {
if (allowedDirectories.length > 0) {
console.error("Client does not support MCP Roots, using allowed directories set from server args:", allowedDirectories);
}else{
throw new Error(`Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories.`);
}
}
};
// Start server
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Secure MCP Filesystem Server running on stdio");
if (allowedDirectories.length === 0) {
console.error("Started without allowed directories - waiting for client to provide roots via MCP protocol");
}
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});

View File

@@ -0,0 +1,415 @@
import fs from "fs/promises";
import path from "path";
import os from 'os';
import { randomBytes } from 'crypto';
import { diffLines, createTwoFilesPatch } from 'diff';
import { minimatch } from 'minimatch';
import { normalizePath, expandHome } from './path-utils.js';
import { isPathWithinAllowedDirectories } from './path-validation.js';
// Global allowed directories - set by the main module
let allowedDirectories: string[] = [];
// Function to set allowed directories from the main module
export function setAllowedDirectories(directories: string[]): void {
allowedDirectories = [...directories];
}
// Function to get current allowed directories
export function getAllowedDirectories(): string[] {
return [...allowedDirectories];
}
// Type definitions
interface FileInfo {
size: number;
created: Date;
modified: Date;
accessed: Date;
isDirectory: boolean;
isFile: boolean;
permissions: string;
}
export interface SearchOptions {
excludePatterns?: string[];
}
export interface SearchResult {
path: string;
isDirectory: boolean;
}
// Pure Utility Functions
export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
if (i < 0 || i === 0) return `${bytes} ${units[0]}`;
const unitIndex = Math.min(i, units.length - 1);
return `${(bytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`;
}
export function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}
export function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string {
// Ensure consistent line endings for diff
const normalizedOriginal = normalizeLineEndings(originalContent);
const normalizedNew = normalizeLineEndings(newContent);
return createTwoFilesPatch(
filepath,
filepath,
normalizedOriginal,
normalizedNew,
'original',
'modified'
);
}
// Helper function to resolve relative paths against allowed directories
function resolveRelativePathAgainstAllowedDirectories(relativePath: string): string {
if (allowedDirectories.length === 0) {
// Fallback to process.cwd() if no allowed directories are set
return path.resolve(process.cwd(), relativePath);
}
// Try to resolve relative path against each allowed directory
for (const allowedDir of allowedDirectories) {
const candidate = path.resolve(allowedDir, relativePath);
const normalizedCandidate = normalizePath(candidate);
// Check if the resulting path lies within any allowed directory
if (isPathWithinAllowedDirectories(normalizedCandidate, allowedDirectories)) {
return candidate;
}
}
// If no valid resolution found, use the first allowed directory as base
// This provides a consistent fallback behavior
return path.resolve(allowedDirectories[0], relativePath);
}
// Security & Validation Functions
export async function validatePath(requestedPath: string): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
: resolveRelativePathAgainstAllowedDirectories(expandedPath);
const normalizedRequested = normalizePath(absolute);
// Security: Check if path is within allowed directories before any file operations
const isAllowed = isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories);
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
}
// Security: Handle symlinks by checking their real path to prevent symlink attacks
// This prevents attackers from creating symlinks that point outside allowed directories
try {
const realPath = await fs.realpath(absolute);
const normalizedReal = normalizePath(realPath);
if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) {
throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(', ')}`);
}
return realPath;
} catch (error) {
// Security: For new files that don't exist yet, verify parent directory
// This ensures we can't create files in unauthorized locations
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
const parentDir = path.dirname(absolute);
try {
const realParentPath = await fs.realpath(parentDir);
const normalizedParent = normalizePath(realParentPath);
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`);
}
return absolute;
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
}
}
throw error;
}
}
// File Operations
export async function getFileStats(filePath: string): Promise<FileInfo> {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
accessed: stats.atime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
permissions: stats.mode.toString(8).slice(-3),
};
}
export async function readFileContent(filePath: string, encoding: string = 'utf-8'): Promise<string> {
return await fs.readFile(filePath, encoding as BufferEncoding);
}
export async function writeFileContent(filePath: string, content: string): Promise<void> {
try {
// Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
// preventing writes through pre-existing symlinks
await fs.writeFile(filePath, content, { encoding: "utf-8", flag: 'wx' });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, content, 'utf-8');
await fs.rename(tempPath, filePath);
} catch (renameError) {
try {
await fs.unlink(tempPath);
} catch {}
throw renameError;
}
} else {
throw error;
}
}
}
// File Editing Functions
interface FileEdit {
oldText: string;
newText: string;
}
export async function applyFileEdits(
filePath: string,
edits: FileEdit[],
dryRun: boolean = false
): Promise<string> {
// Read file content and normalize line endings
const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8'));
// Apply edits sequentially
let modifiedContent = content;
for (const edit of edits) {
const normalizedOld = normalizeLineEndings(edit.oldText);
const normalizedNew = normalizeLineEndings(edit.newText);
// If exact match exists, use it
if (modifiedContent.includes(normalizedOld)) {
modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew);
continue;
}
// Otherwise, try line-by-line matching with flexibility for whitespace
const oldLines = normalizedOld.split('\n');
const contentLines = modifiedContent.split('\n');
let matchFound = false;
for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
const potentialMatch = contentLines.slice(i, i + oldLines.length);
// Compare lines with normalized whitespace
const isMatch = oldLines.every((oldLine, j) => {
const contentLine = potentialMatch[j];
return oldLine.trim() === contentLine.trim();
});
if (isMatch) {
// Preserve original indentation of first line
const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return originalIndent + line.trimStart();
// For subsequent lines, try to preserve relative indentation
const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '';
const newIndent = line.match(/^\s*/)?.[0] || '';
if (oldIndent && newIndent) {
const relativeIndent = newIndent.length - oldIndent.length;
return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart();
}
return line;
});
contentLines.splice(i, oldLines.length, ...newLines);
modifiedContent = contentLines.join('\n');
matchFound = true;
break;
}
}
if (!matchFound) {
throw new Error(`Could not find exact match for edit:\n${edit.oldText}`);
}
}
// Create unified diff
const diff = createUnifiedDiff(content, modifiedContent, filePath);
// Format diff with appropriate number of backticks
let numBackticks = 3;
while (diff.includes('`'.repeat(numBackticks))) {
numBackticks++;
}
const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`;
if (!dryRun) {
// Security: Use atomic rename to prevent race conditions where symlinks
// could be created between validation and write. Rename operations
// replace the target file atomically and don't follow symlinks.
const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`;
try {
await fs.writeFile(tempPath, modifiedContent, 'utf-8');
await fs.rename(tempPath, filePath);
} catch (error) {
try {
await fs.unlink(tempPath);
} catch {}
throw error;
}
}
return formattedDiff;
}
// Memory-efficient implementation to get the last N lines of a file
export async function tailFile(filePath: string, numLines: number): Promise<string> {
const CHUNK_SIZE = 1024; // Read 1KB at a time
const stats = await fs.stat(filePath);
const fileSize = stats.size;
if (fileSize === 0) return '';
// Open file for reading
const fileHandle = await fs.open(filePath, 'r');
try {
const lines: string[] = [];
let position = fileSize;
let chunk = Buffer.alloc(CHUNK_SIZE);
let linesFound = 0;
let remainingText = '';
// Read chunks from the end of the file until we have enough lines
while (position > 0 && linesFound < numLines) {
const size = Math.min(CHUNK_SIZE, position);
position -= size;
const { bytesRead } = await fileHandle.read(chunk, 0, size, position);
if (!bytesRead) break;
// Get the chunk as a string and prepend any remaining text from previous iteration
const readData = chunk.slice(0, bytesRead).toString('utf-8');
const chunkText = readData + remainingText;
// Split by newlines and count
const chunkLines = normalizeLineEndings(chunkText).split('\n');
// If this isn't the end of the file, the first line is likely incomplete
// Save it to prepend to the next chunk
if (position > 0) {
remainingText = chunkLines[0];
chunkLines.shift(); // Remove the first (incomplete) line
}
// Add lines to our result (up to the number we need)
for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) {
lines.unshift(chunkLines[i]);
linesFound++;
}
}
return lines.join('\n');
} finally {
await fileHandle.close();
}
}
// New function to get the first N lines of a file
export async function headFile(filePath: string, numLines: number): Promise<string> {
const fileHandle = await fs.open(filePath, 'r');
try {
const lines: string[] = [];
let buffer = '';
let bytesRead = 0;
const chunk = Buffer.alloc(1024); // 1KB buffer
// Read chunks and count lines until we have enough or reach EOF
while (lines.length < numLines) {
const result = await fileHandle.read(chunk, 0, chunk.length, bytesRead);
if (result.bytesRead === 0) break; // End of file
bytesRead += result.bytesRead;
buffer += chunk.slice(0, result.bytesRead).toString('utf-8');
const newLineIndex = buffer.lastIndexOf('\n');
if (newLineIndex !== -1) {
const completeLines = buffer.slice(0, newLineIndex).split('\n');
buffer = buffer.slice(newLineIndex + 1);
for (const line of completeLines) {
lines.push(line);
if (lines.length >= numLines) break;
}
}
}
// If there is leftover content and we still need lines, add it
if (buffer.length > 0 && lines.length < numLines) {
lines.push(buffer);
}
return lines.join('\n');
} finally {
await fileHandle.close();
}
}
export async function searchFilesWithValidation(
rootPath: string,
pattern: string,
allowedDirectories: string[],
options: SearchOptions = {}
): Promise<string[]> {
const { excludePatterns = [] } = options;
const results: string[] = [];
async function search(currentPath: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
try {
await validatePath(fullPath);
const relativePath = path.relative(rootPath, fullPath);
const shouldExclude = excludePatterns.some(excludePattern =>
minimatch(relativePath, excludePattern, { dot: true })
);
if (shouldExclude) continue;
// Use glob matching for the search pattern
if (minimatch(relativePath, pattern, { dot: true })) {
results.push(fullPath);
}
if (entry.isDirectory()) {
await search(fullPath);
}
} catch {
continue;
}
}
}
await search(rootPath);
return results;
}

View File

@@ -0,0 +1,43 @@
{
"name": "@modelcontextprotocol/server-filesystem",
"version": "0.6.3",
"description": "MCP server for filesystem access",
"license": "SEE LICENSE IN LICENSE",
"mcpName": "io.github.modelcontextprotocol/server-filesystem",
"author": "Model Context Protocol a Series of LF Projects, LLC.",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/servers/issues",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/servers.git"
},
"type": "module",
"bin": {
"mcp-server-filesystem": "dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && shx chmod +x dist/*.js",
"prepare": "npm run build",
"watch": "tsc --watch",
"test": "vitest run --coverage"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.26.0",
"diff": "^8.0.3",
"glob": "^10.5.0",
"minimatch": "^10.0.1",
"zod-to-json-schema": "^3.23.5"
},
"devDependencies": {
"@types/diff": "^5.0.9",
"@types/minimatch": "^5.1.2",
"@types/node": "^22",
"@vitest/coverage-v8": "^2.1.8",
"shx": "^0.3.4",
"typescript": "^5.8.2",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,125 @@
import path from "path";
import os from 'os';
/**
* Converts WSL or Unix-style Windows paths to Windows format
* @param p The path to convert
* @returns Converted Windows path
*/
export function convertToWindowsPath(p: string): string {
// Handle WSL paths (/mnt/c/...)
// NEVER convert WSL paths - they are valid Linux paths that work with Node.js fs operations in WSL
// Converting them to Windows format (C:\...) breaks fs operations inside WSL
if (p.startsWith('/mnt/')) {
return p; // Leave WSL paths unchanged
}
// Handle Unix-style Windows paths (/c/...)
// Only convert when running on Windows
if (p.match(/^\/[a-zA-Z]\//) && process.platform === 'win32') {
const driveLetter = p.charAt(1).toUpperCase();
const pathPart = p.slice(2).replace(/\//g, '\\');
return `${driveLetter}:${pathPart}`;
}
// Handle standard Windows paths, ensuring backslashes
if (p.match(/^[a-zA-Z]:/)) {
return p.replace(/\//g, '\\');
}
// Leave non-Windows paths unchanged
return p;
}
/**
* Normalizes path by standardizing format while preserving OS-specific behavior
* @param p The path to normalize
* @returns Normalized path
*/
export function normalizePath(p: string): string {
// Remove any surrounding quotes and whitespace
p = p.trim().replace(/^["']|["']$/g, '');
// Check if this is a Unix path that should not be converted
// WSL paths (/mnt/) should ALWAYS be preserved as they work correctly in WSL with Node.js fs
// Regular Unix paths should also be preserved
const isUnixPath = p.startsWith('/') && (
// Always preserve WSL paths (/mnt/c/, /mnt/d/, etc.)
p.match(/^\/mnt\/[a-z]\//i) ||
// On non-Windows platforms, treat all absolute paths as Unix paths
(process.platform !== 'win32') ||
// On Windows, preserve Unix paths that aren't Unix-style Windows paths (/c/, /d/, etc.)
(process.platform === 'win32' && !p.match(/^\/[a-zA-Z]\//))
);
if (isUnixPath) {
// For Unix paths, just normalize without converting to Windows format
// Replace double slashes with single slashes and remove trailing slashes
return p.replace(/\/+/g, '/').replace(/(?<!^)\/$/, '');
}
// Convert Unix-style Windows paths (/c/, /d/) to Windows format if on Windows
// This function will now leave /mnt/ paths unchanged
p = convertToWindowsPath(p);
// Handle double backslashes, preserving leading UNC \\
if (p.startsWith('\\\\')) {
// For UNC paths, first normalize any excessive leading backslashes to exactly \\
// Then normalize double backslashes in the rest of the path
let uncPath = p;
// Replace multiple leading backslashes with exactly two
uncPath = uncPath.replace(/^\\{2,}/, '\\\\');
// Now normalize any remaining double backslashes in the rest of the path
const restOfPath = uncPath.substring(2).replace(/\\\\/g, '\\');
p = '\\\\' + restOfPath;
} else {
// For non-UNC paths, normalize all double backslashes
p = p.replace(/\\\\/g, '\\');
}
// On Windows, if we have a bare drive letter (e.g. "C:"), append a separator
// so path.normalize doesn't return "C:." which can break path validation.
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(p)) {
p = p + path.sep;
}
// Use Node's path normalization, which handles . and .. segments
let normalized = path.normalize(p);
// Fix UNC paths after normalization (path.normalize can remove a leading backslash)
if (p.startsWith('\\\\') && !normalized.startsWith('\\\\')) {
normalized = '\\' + normalized;
}
// Handle Windows paths: convert slashes and ensure drive letter is capitalized
if (normalized.match(/^[a-zA-Z]:/)) {
let result = normalized.replace(/\//g, '\\');
// Capitalize drive letter if present
if (/^[a-z]:/.test(result)) {
result = result.charAt(0).toUpperCase() + result.slice(1);
}
return result;
}
// On Windows, convert forward slashes to backslashes for relative paths
// On Linux/Unix, preserve forward slashes
if (process.platform === 'win32') {
return normalized.replace(/\//g, '\\');
}
// On non-Windows platforms, keep the normalized path as-is
return normalized;
}
/**
* Expands home directory tildes in paths
* @param filepath The path to expand
* @returns Expanded path
*/
export function expandHome(filepath: string): string {
if (filepath.startsWith('~/') || filepath === '~') {
return path.join(os.homedir(), filepath.slice(1));
}
return filepath;
}

View File

@@ -0,0 +1,86 @@
import path from 'path';
/**
* Checks if an absolute path is within any of the allowed directories.
*
* @param absolutePath - The absolute path to check (will be normalized)
* @param allowedDirectories - Array of absolute allowed directory paths (will be normalized)
* @returns true if the path is within an allowed directory, false otherwise
* @throws Error if given relative paths after normalization
*/
export function isPathWithinAllowedDirectories(absolutePath: string, allowedDirectories: string[]): boolean {
// Type validation
if (typeof absolutePath !== 'string' || !Array.isArray(allowedDirectories)) {
return false;
}
// Reject empty inputs
if (!absolutePath || allowedDirectories.length === 0) {
return false;
}
// Reject null bytes (forbidden in paths)
if (absolutePath.includes('\x00')) {
return false;
}
// Normalize the input path
let normalizedPath: string;
try {
normalizedPath = path.resolve(path.normalize(absolutePath));
} catch {
return false;
}
// Verify it's absolute after normalization
if (!path.isAbsolute(normalizedPath)) {
throw new Error('Path must be absolute after normalization');
}
// Check against each allowed directory
return allowedDirectories.some(dir => {
if (typeof dir !== 'string' || !dir) {
return false;
}
// Reject null bytes in allowed dirs
if (dir.includes('\x00')) {
return false;
}
// Normalize the allowed directory
let normalizedDir: string;
try {
normalizedDir = path.resolve(path.normalize(dir));
} catch {
return false;
}
// Verify allowed directory is absolute after normalization
if (!path.isAbsolute(normalizedDir)) {
throw new Error('Allowed directories must be absolute paths after normalization');
}
// Check if normalizedPath is within normalizedDir
// Path is inside if it's the same or a subdirectory
if (normalizedPath === normalizedDir) {
return true;
}
// Special case for root directory to avoid double slash
// On Windows, we need to check if both paths are on the same drive
if (normalizedDir === path.sep) {
return normalizedPath.startsWith(path.sep);
}
// On Windows, also check for drive root (e.g., "C:\")
if (path.sep === '\\' && normalizedDir.match(/^[A-Za-z]:\\?$/)) {
// Ensure both paths are on the same drive
const dirDrive = normalizedDir.charAt(0).toLowerCase();
const pathDrive = normalizedPath.charAt(0).toLowerCase();
return pathDrive === dirDrive && normalizedPath.startsWith(normalizedDir.replace(/\\?$/, '\\'));
}
return normalizedPath.startsWith(normalizedDir + path.sep);
});
}

View File

@@ -0,0 +1,77 @@
import { promises as fs, type Stats } from 'fs';
import path from 'path';
import os from 'os';
import { normalizePath } from './path-utils.js';
import type { Root } from '@modelcontextprotocol/sdk/types.js';
import { fileURLToPath } from "url";
/**
* Converts a root URI to a normalized directory path with basic security validation.
* @param rootUri - File URI (file://...) or plain directory path
* @returns Promise resolving to validated path or null if invalid
*/
async function parseRootUri(rootUri: string): Promise<string | null> {
try {
const rawPath = rootUri.startsWith('file://') ? fileURLToPath(rootUri) : rootUri;
const expandedPath = rawPath.startsWith('~/') || rawPath === '~'
? path.join(os.homedir(), rawPath.slice(1))
: rawPath;
const absolutePath = path.resolve(expandedPath);
const resolvedPath = await fs.realpath(absolutePath);
return normalizePath(resolvedPath);
} catch {
return null; // Path doesn't exist or other error
}
}
/**
* Formats error message for directory validation failures.
* @param dir - Directory path that failed validation
* @param error - Error that occurred during validation
* @param reason - Specific reason for failure
* @returns Formatted error message
*/
function formatDirectoryError(dir: string, error?: unknown, reason?: string): string {
if (reason) {
return `Skipping ${reason}: ${dir}`;
}
const message = error instanceof Error ? error.message : String(error);
return `Skipping invalid directory: ${dir} due to error: ${message}`;
}
/**
* Resolves requested root directories from MCP root specifications.
*
* Converts root URI specifications (file:// URIs or plain paths) into normalized
* directory paths, validating that each path exists and is a directory.
* Includes symlink resolution for security.
*
* @param requestedRoots - Array of root specifications with URI and optional name
* @returns Promise resolving to array of validated directory paths
*/
export async function getValidRootDirectories(
requestedRoots: readonly Root[]
): Promise<string[]> {
const validatedDirectories: string[] = [];
for (const requestedRoot of requestedRoots) {
const resolvedPath = await parseRootUri(requestedRoot.uri);
if (!resolvedPath) {
console.error(formatDirectoryError(requestedRoot.uri, undefined, 'invalid path or inaccessible'));
continue;
}
try {
const stats: Stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
validatedDirectories.push(resolvedPath);
} else {
console.error(formatDirectoryError(resolvedPath, undefined, 'non-directory root'));
}
} catch (error) {
console.error(formatDirectoryError(resolvedPath, error));
}
}
return validatedDirectories;
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "NodeNext",
"module": "NodeNext"
},
"include": [
"./**/*.ts"
],
"exclude": [
"**/__tests__/**",
"**/*.test.ts",
"**/*.spec.ts",
"vitest.config.ts"
]
}

Some files were not shown because too many files have changed in this diff Show More