wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation
This commit is contained in:
1
.agent/services/mcp-core/.gitattributes
vendored
Normal file
1
.agent/services/mcp-core/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
package-lock.json linguist-generated=true
|
||||
44
.agent/services/mcp-core/.github/pull_request_template.md
vendored
Normal file
44
.agent/services/mcp-core/.github/pull_request_template.md
vendored
Normal 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 -->
|
||||
49
.agent/services/mcp-core/.github/workflows/claude.yml
vendored
Normal file
49
.agent/services/mcp-core/.github/workflows/claude.yml
vendored
Normal 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."
|
||||
121
.agent/services/mcp-core/.github/workflows/python.yml
vendored
Normal file
121
.agent/services/mcp-core/.github/workflows/python.yml
vendored
Normal 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
|
||||
111
.agent/services/mcp-core/.github/workflows/readme-pr-check.yml
vendored
Normal file
111
.agent/services/mcp-core/.github/workflows/readme-pr-check.yml
vendored
Normal 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);
|
||||
}
|
||||
222
.agent/services/mcp-core/.github/workflows/release.yml
vendored
Normal file
222
.agent/services/mcp-core/.github/workflows/release.yml
vendored
Normal 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
|
||||
|
||||
102
.agent/services/mcp-core/.github/workflows/typescript.yml
vendored
Normal file
102
.agent/services/mcp-core/.github/workflows/typescript.yml
vendored
Normal 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
305
.agent/services/mcp-core/.gitignore
vendored
Normal 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
|
||||
8
.agent/services/mcp-core/.mcp.json
Normal file
8
.agent/services/mcp-core/.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp-docs": {
|
||||
"type": "http",
|
||||
"url": "https://modelcontextprotocol.io/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.agent/services/mcp-core/.npmrc
Normal file
2
.agent/services/mcp-core/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
registry="https://registry.npmjs.org/"
|
||||
@modelcontextprotocol:registry="https://registry.npmjs.org/"
|
||||
128
.agent/services/mcp-core/CODE_OF_CONDUCT.md
Normal file
128
.agent/services/mcp-core/CODE_OF_CONDUCT.md
Normal 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.
|
||||
40
.agent/services/mcp-core/CONTRIBUTING.md
Normal file
40
.agent/services/mcp-core/CONTRIBUTING.md
Normal 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!
|
||||
216
.agent/services/mcp-core/LICENSE
Normal file
216
.agent/services/mcp-core/LICENSE
Normal 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.
|
||||
1657
.agent/services/mcp-core/README.md
Normal file
1657
.agent/services/mcp-core/README.md
Normal file
File diff suppressed because it is too large
Load Diff
21
.agent/services/mcp-core/SECURITY.md
Normal file
21
.agent/services/mcp-core/SECURITY.md
Normal 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
4044
.agent/services/mcp-core/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
.agent/services/mcp-core/package.json
Normal file
27
.agent/services/mcp-core/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
213
.agent/services/mcp-core/scripts/release.py
Normal file
213
.agent/services/mcp-core/scripts/release.py
Normal 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())
|
||||
4
.agent/services/mcp-core/src/everything/.prettierignore
Normal file
4
.agent/services/mcp-core/src/everything/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
packages
|
||||
dist
|
||||
README.md
|
||||
node_modules
|
||||
52
.agent/services/mcp-core/src/everything/AGENTS.md
Normal file
52
.agent/services/mcp-core/src/everything/AGENTS.md
Normal 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.
|
||||
22
.agent/services/mcp-core/src/everything/Dockerfile
Normal file
22
.agent/services/mcp-core/src/everything/Dockerfile
Normal 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"]
|
||||
106
.agent/services/mcp-core/src/everything/README.md
Normal file
106
.agent/services/mcp-core/src/everything/README.md
Normal 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...
|
||||
|
||||
[](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) [](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)
|
||||
|
||||
[](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) [](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
|
||||
```
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
820
.agent/services/mcp-core/src/everything/__tests__/tools.test.ts
Normal file
820
.agent/services/mcp-core/src/everything/__tests__/tools.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
.agent/services/mcp-core/src/everything/docs/architecture.md
Normal file
44
.agent/services/mcp-core/src/everything/docs/architecture.md
Normal 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.
|
||||
|
||||
## High‑level 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.
|
||||
|
||||
### Multi‑client
|
||||
|
||||
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)
|
||||
23
.agent/services/mcp-core/src/everything/docs/extension.md
Normal file
23
.agent/services/mcp-core/src/everything/docs/extension.md
Normal 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)`.
|
||||
103
.agent/services/mcp-core/src/everything/docs/features.md
Normal file
103
.agent/services/mcp-core/src/everything/docs/features.md
Normal 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` (1–10), 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 backward‑compatible `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, random‑leveled logging for the invoking session. Respects the client’s 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 SDK’s `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 opt‑in 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 per‑session interval that emits `notifications/resources/updated { uri }` only for URIs that session has subscribed to.
|
||||
- Multiple concurrent clients are supported; each client’s 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
|
||||
45
.agent/services/mcp-core/src/everything/docs/how-it-works.md
Normal file
45
.agent/services/mcp-core/src/everything/docs/how-it-works.md
Normal 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 session‑scoped state.
|
||||
|
||||
## Session‑scoped 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 client’s configured minimum logging level is respected by the SDK.
|
||||
28
.agent/services/mcp-core/src/everything/docs/instructions.md
Normal file
28
.agent/services/mcp-core/src/everything/docs/instructions.md
Normal 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."
|
||||
73
.agent/services/mcp-core/src/everything/docs/startup.md
Normal file
73
.agent/services/mcp-core/src/everything/docs/startup.md
Normal 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, process‑bound 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 server’s `onclose` to clean and remove session.
|
||||
- Exposes
|
||||
- `/sse` **GET** (SSE stream)
|
||||
- `/message` **POST** (JSON‑RPC messages)
|
||||
- **Streamable HTTP**:
|
||||
- Supports multiple client connections.
|
||||
- Client transports are mapped to `sessionId`;
|
||||
- Calls `clientConnect(sessionId)` upon connection.
|
||||
- Exposes `/mcp` for
|
||||
- **POST** (JSON‑RPC 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 session‑scoped 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.
|
||||
182
.agent/services/mcp-core/src/everything/docs/structure.md
Normal file
182
.agent/services/mcp-core/src/everything/docs/structure.md
Normal 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`
|
||||
- Human‑readable 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 SDK’s `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, template‑driven 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 (JSON‑RPC), 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.
|
||||
42
.agent/services/mcp-core/src/everything/index.ts
Normal file
42
.agent/services/mcp-core/src/everything/index.ts
Normal 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();
|
||||
49
.agent/services/mcp-core/src/everything/package.json
Normal file
49
.agent/services/mcp-core/src/everything/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
41
.agent/services/mcp-core/src/everything/prompts/args.ts
Normal file
41
.agent/services/mcp-core/src/everything/prompts/args.ts
Normal 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}?`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
};
|
||||
17
.agent/services/mcp-core/src/everything/prompts/index.ts
Normal file
17
.agent/services/mcp-core/src/everything/prompts/index.ts
Normal 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);
|
||||
};
|
||||
93
.agent/services/mcp-core/src/everything/prompts/resource.ts
Normal file
93
.agent/services/mcp-core/src/everything/prompts/resource.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
29
.agent/services/mcp-core/src/everything/prompts/simple.ts
Normal file
29
.agent/services/mcp-core/src/everything/prompts/simple.ts
Normal 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.",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
};
|
||||
89
.agent/services/mcp-core/src/everything/resources/files.ts
Normal file
89
.agent/services/mcp-core/src/everything/resources/files.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
36
.agent/services/mcp-core/src/everything/resources/index.ts
Normal file
36
.agent/services/mcp-core/src/everything/resources/index.ts
Normal 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;
|
||||
}
|
||||
80
.agent/services/mcp-core/src/everything/resources/session.ts
Normal file
80
.agent/services/mcp-core/src/everything/resources/session.ts
Normal 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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
211
.agent/services/mcp-core/src/everything/resources/templates.ts
Normal file
211
.agent/services/mcp-core/src/everything/resources/templates.ts
Normal 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)],
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
118
.agent/services/mcp-core/src/everything/server/index.ts
Normal file
118
.agent/services/mcp-core/src/everything/server/index.ts
Normal 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;
|
||||
};
|
||||
82
.agent/services/mcp-core/src/everything/server/logging.ts
Normal file
82
.agent/services/mcp-core/src/everything/server/logging.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
90
.agent/services/mcp-core/src/everything/server/roots.ts
Normal file
90
.agent/services/mcp-core/src/everything/server/roots.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
34
.agent/services/mcp-core/src/everything/tools/echo.ts
Normal file
34
.agent/services/mcp-core/src/everything/tools/echo.ts
Normal 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}` }],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
33
.agent/services/mcp-core/src/everything/tools/get-env.ts
Normal file
33
.agent/services/mcp-core/src/everything/tools/get-env.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
@@ -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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
45
.agent/services/mcp-core/src/everything/tools/get-sum.ts
Normal file
45
.agent/services/mcp-core/src/everything/tools/get-sum.ts
Normal 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
@@ -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);
|
||||
}
|
||||
}
|
||||
53
.agent/services/mcp-core/src/everything/tools/index.ts
Normal file
53
.agent/services/mcp-core/src/everything/tools/index.ts
Normal 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);
|
||||
};
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
@@ -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}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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}.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
77
.agent/services/mcp-core/src/everything/transports/sse.ts
Normal file
77
.agent/services/mcp-core/src/everything/transports/sse.ts
Normal 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}`);
|
||||
});
|
||||
33
.agent/services/mcp-core/src/everything/transports/stdio.ts
Normal file
33
.agent/services/mcp-core/src/everything/transports/stdio.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
8
.agent/services/mcp-core/src/everything/tsconfig.json
Normal file
8
.agent/services/mcp-core/src/everything/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
14
.agent/services/mcp-core/src/everything/vitest.config.ts
Normal file
14
.agent/services/mcp-core/src/everything/vitest.config.ts
Normal 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/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
1
.agent/services/mcp-core/src/fetch/.python-version
Normal file
1
.agent/services/mcp-core/src/fetch/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
36
.agent/services/mcp-core/src/fetch/Dockerfile
Normal file
36
.agent/services/mcp-core/src/fetch/Dockerfile
Normal 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"]
|
||||
7
.agent/services/mcp-core/src/fetch/LICENSE
Normal file
7
.agent/services/mcp-core/src/fetch/LICENSE
Normal 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.
|
||||
241
.agent/services/mcp-core/src/fetch/README.md
Normal file
241
.agent/services/mcp-core/src/fetch/README.md
Normal 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...
|
||||
|
||||
[](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) [](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)
|
||||
|
||||
[](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) [](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.
|
||||
40
.agent/services/mcp-core/src/fetch/pyproject.toml
Normal file
40
.agent/services/mcp-core/src/fetch/pyproject.toml
Normal 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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
# __main__.py
|
||||
|
||||
from mcp_server_fetch import main
|
||||
|
||||
main()
|
||||
@@ -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)
|
||||
326
.agent/services/mcp-core/src/fetch/tests/test_server.py
Normal file
326
.agent/services/mcp-core/src/fetch/tests/test_server.py
Normal 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
1285
.agent/services/mcp-core/src/fetch/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
.agent/services/mcp-core/src/filesystem/Dockerfile
Normal file
25
.agent/services/mcp-core/src/filesystem/Dockerfile
Normal 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"]
|
||||
321
.agent/services/mcp-core/src/filesystem/README.md
Normal file
321
.agent/services/mcp-core/src/filesystem/README.md
Normal 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 **read‑only** tools from write‑capable 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` | Re‑creating the same dir is a no‑op |
|
||||
| `write_file` | `false` | `true` | `true` | Overwrites existing files |
|
||||
| `edit_file` | `false` | `false` | `true` | Re‑applying edits can fail or double‑apply |
|
||||
| `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...
|
||||
|
||||
[](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) [](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)
|
||||
|
||||
[](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) [](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.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
725
.agent/services/mcp-core/src/filesystem/__tests__/lib.test.ts
Normal file
725
.agent/services/mcp-core/src/filesystem/__tests__/lib.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
767
.agent/services/mcp-core/src/filesystem/index.ts
Normal file
767
.agent/services/mcp-core/src/filesystem/index.ts
Normal 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);
|
||||
});
|
||||
415
.agent/services/mcp-core/src/filesystem/lib.ts
Normal file
415
.agent/services/mcp-core/src/filesystem/lib.ts
Normal 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;
|
||||
}
|
||||
43
.agent/services/mcp-core/src/filesystem/package.json
Normal file
43
.agent/services/mcp-core/src/filesystem/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
125
.agent/services/mcp-core/src/filesystem/path-utils.ts
Normal file
125
.agent/services/mcp-core/src/filesystem/path-utils.ts
Normal 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;
|
||||
}
|
||||
|
||||
86
.agent/services/mcp-core/src/filesystem/path-validation.ts
Normal file
86
.agent/services/mcp-core/src/filesystem/path-validation.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
77
.agent/services/mcp-core/src/filesystem/roots-utils.ts
Normal file
77
.agent/services/mcp-core/src/filesystem/roots-utils.ts
Normal 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;
|
||||
}
|
||||
18
.agent/services/mcp-core/src/filesystem/tsconfig.json
Normal file
18
.agent/services/mcp-core/src/filesystem/tsconfig.json
Normal 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
Reference in New Issue
Block a user