├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── SECURITY.md ├── SUPPORT.md ├── package-lock.json ├── package.json ├── readme.md ├── src ├── auth.ts ├── environment.ts ├── express-routes │ ├── file-stream.ts │ ├── index.ts │ └── oauth-resource.ts ├── index.ts ├── method-context.ts ├── resourceTemplates.ts ├── resourceTemplates │ ├── common.ts │ ├── file.ts │ └── site.ts ├── resources.ts ├── resources │ ├── common.ts │ ├── core │ │ ├── default-resource-handler.ts │ │ ├── process-resource-handlers.ts │ │ └── utils.ts │ ├── file.ts │ ├── folder.ts │ ├── library.ts │ ├── list.ts │ └── site.ts ├── session.ts ├── setup-express-server-sse.ts ├── setup-express-server-streamablehttp.ts ├── setup-mcp-server.ts ├── tools.ts ├── tools │ ├── clear-context.ts │ ├── core │ │ └── utils.ts │ ├── create-file.ts │ ├── get-context.ts │ ├── get-file.ts │ ├── hidden │ │ ├── get-drive.ts │ │ ├── readme.txt │ │ ├── test-long-running.ts │ │ └── test-paging.ts │ ├── list-files.ts │ ├── list-libraries.ts │ ├── list-listitems.ts │ ├── list-lists.ts │ ├── list-sites.ts │ └── set-context.ts ├── types.ts └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | /src/settings.ts 2 | /build 3 | /build-scripts 4 | 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | dist 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | .cache 111 | 112 | # vitepress build output 113 | **/.vitepress/dist 114 | 115 | # vitepress cache directory 116 | **/.vitepress/cache 117 | 118 | # Docusaurus cache and generated files 119 | .docusaurus 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | # Stores VSCode versions used for testing VSCode extensions 134 | .vscode-test 135 | 136 | # yarn v2 137 | .yarn/cache 138 | .yarn/unplugged 139 | .yarn/build-state.yml 140 | .yarn/install-state.gz 141 | .pnp.* 142 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/src/index.ts", 9 | "args": [], 10 | "cwd": "${workspaceRoot}", 11 | "preLaunchTask": "build", 12 | "runtimeExecutable": null, 13 | "runtimeArgs": [ 14 | "--nolazy" 15 | ], 16 | "console": "internalConsole", 17 | "internalConsoleOptions": "openOnSessionStart", 18 | "sourceMaps": true, 19 | "outFiles": [ 20 | "${workspaceRoot}/build/**/*.js" 21 | ], 22 | "skipFiles": [ 23 | "/**" 24 | ], 25 | "envFile": "${workspaceFolder}/.env" 26 | }, 27 | { 28 | "name": "Debug Watch", 29 | "type": "node", 30 | "request": "launch", 31 | "program": "${workspaceRoot}/src/index.ts", 32 | "args": [], 33 | "cwd": "${workspaceRoot}", 34 | "preLaunchTask": "build-watch", 35 | "runtimeExecutable": "nodemon", 36 | "runtimeArgs": [ 37 | "--nolazy", 38 | "--watch", 39 | "build" 40 | ], 41 | "console": "internalConsole", 42 | "internalConsoleOptions": "openOnSessionStart", 43 | "sourceMaps": true, 44 | "outFiles": [ 45 | "${workspaceRoot}/build/**/*.js" 46 | ], 47 | "skipFiles": [ 48 | "/**" 49 | ], 50 | "envFile": "${workspaceFolder}/.env" 51 | } 52 | ] 53 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "**/.git": true, 5 | "**/.DS_Store": true, 6 | "**/node_modules": true, 7 | "coverage": true, 8 | ".nyc_output": true, 9 | "build": true, 10 | "build-scripts": true, 11 | }, 12 | "typescript.validate.enable": true, 13 | "typescript.tsdk": "./node_modules/typescript/lib", 14 | "editor.tabSize": 4, 15 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "npx", 8 | "args": [ 9 | "tsc", 10 | "-p", 11 | ".", 12 | ], 13 | "problemMatcher": [ 14 | "$tsc", 15 | "$jshint" 16 | ], 17 | "presentation": { 18 | "echo": true, 19 | "reveal": "always", 20 | "focus": false, 21 | "panel": "shared", 22 | "showReuseMessage": false 23 | } 24 | }, 25 | { 26 | "label": "build-watch", 27 | "type": "shell", 28 | "command": "npx", 29 | "args": [ 30 | "tsc", 31 | "-p", 32 | ".", 33 | "--watch" 34 | ], 35 | "isBackground": true, 36 | "problemMatcher": { 37 | "owner": "typescript", 38 | "fileLocation": "relative", 39 | "pattern": { 40 | "regexp": "^([^\\s].*)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$", 41 | "file": 1, 42 | "location": 2, 43 | "severity": 3, 44 | "code": 4, 45 | "message": 5 46 | }, 47 | "background": { 48 | "activeOnStart": true, 49 | "beginsPattern": "Starting incremental compilation\\.\\.\\.$", 50 | "endsPattern": "Watching for file changes\\.$" 51 | 52 | } 53 | } 54 | }, 55 | ] 56 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | - Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome your contributions! 4 | 5 | You can contibute both to the overall solution, and by adding/updating tools. 6 | 7 | ## Tools 8 | 9 | You can easily contribute new tools in the `/src/tools` folder by adding a module and following the pattern: 10 | 11 | ```TS 12 | export const name = "{NAME OF THE TOOL}"; 13 | 14 | export const description = "{DESCRIPTION OF THE TOOL}"; 15 | 16 | export const inputSchema = {}; // ANY REQUIRED INPUT SCHEMA 17 | 18 | // UPDATE THE HANDLER LOGIC AS REQUIRED 19 | export const handler = async function (this: ToolContext, request: CallToolRequest): Promise { 20 | 21 | return this.fetch("https://graph.microsoft.com/v1.0/drives"); 22 | }; 23 | ``` 24 | 25 | ## Project 26 | 27 | We welcome improvements/ideas for the main project as well, if you have ideas for large changes, please open an issue to discuss before doing the work. We'd hate for you to invest time in an area where we may have other plans. 28 | 29 | ## Process 30 | 31 | Regardless of your change, big or small, open a PR against MAIN for review. We will review and respond, please remain engaged in case we have any questions. Once approved we will merge your PR. 32 | 33 | We do not have a set release schedule, but once merged your changes will be included in the next NPM release. 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 6 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 7 | feature request as a new Issue. 8 | 9 | For help and questions about using this project, please open an issue. 10 | 11 | ## Microsoft Support Policy 12 | 13 | This library is supplied as-is and is not meant for use in production. We will do our best or provide support, but there is no SLA for issue resolution or releases. This project is meant to provide support for developer type experiences interacting with a local MCP server. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@microsoft/files-mcp-server", 3 | "version": "0.0.1", 4 | "description": "An MCP server for M365 Files", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "bin": { 8 | "files-mcp-server": "build/index.js" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "build": "tsc -p . && shx chmod +x build/*.js", 13 | "prepare": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/microsoft/files-mcp-server.git" 18 | }, 19 | "author": "", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/microsoft/files-mcp-server/issues" 23 | }, 24 | "homepage": "https://github.com/microsoft/files-mcp-server#readme", 25 | "devDependencies": { 26 | "@types/express": "^5.0.1", 27 | "@types/node": "^18.19.84", 28 | "nodemon": "^3.1.10", 29 | "shx": "^0.4.0", 30 | "typescript": "^5.8.2" 31 | }, 32 | "dependencies": { 33 | "@modelcontextprotocol/sdk": "^1.12.0", 34 | "@pnp/logging": "^4.11.0", 35 | "express": "^5.1.0" 36 | } 37 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Files MCP Server 2 | 3 | This library provides an MCP server for local testing with any client that supports the [Model Context Protocol](https://modelcontextprotocol.io/introduction). 4 | 5 | It is an http server using delegated authentication to access your environment. 6 | 7 | ## Scopes 8 | 9 | This sample uses the *Files.ReadWrite.All* and *Sites.Read.All* delegated Graph scopes. 10 | 11 | ## Install 12 | 13 | 1. Clone this repository locally (will update once we are published to NPM) 14 | 2. In your MCP client of choice add this server using `npx -y {ABSOLUTE LOCAL PATH}\files-mcp-server` 15 | 3. Edit the server configuration to include the require env vars 16 | ```json 17 | { 18 | "mcp": { 19 | "servers": { 20 | "files-localhost-debug": { 21 | "type": "http", 22 | "url": "http://localhost:3001/mcp", 23 | } 24 | } 25 | } 26 | } 27 | ``` 28 | 4. Begin interacting with the server 29 | 30 | ## Local Development 31 | 32 | 1. Create a .env file 33 | 34 | ``` 35 | ODMCP_TENANT_ID="{TENANT_ID}" 36 | ODMCP_CLIENT_ID="{CLIENT_ID}" 37 | ``` 38 | 2. Hit F5 to start the server 39 | 3. [Inspector](https://github.com/modelcontextprotocol/inspector) works well for testing the MCP server itself or use your LLM Client of choice! 40 | 41 | > To test the delegated authentication flow as of June 5 Visual Studio Code insiders supports the [protected resource flow](https://datatracker.ietf.org/doc/html/rfc9728). 42 | 43 | ## Trademarks 44 | 45 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow Microsoft’s Trademark & Brand Guidelines. Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. Any use of third-party trademarks or logos are subject to those third-party’s policies. 46 | 47 | ## Usage 48 | 49 | **PLEASE USE THIS ONLY IN A DEVELOPER ENVIRONMENT — NOT FOR PRODUCTION** 50 | 51 | > For more information, see the [Microsoft identity platform security guidance](https://learn.microsoft.com/en-us/entra/identity-platform/secure-least-privileged-access). 52 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | import { clientId } from "./environment.js"; 3 | 4 | export function requireAuthentication(wrapped: RequestHandler): RequestHandler { 5 | 6 | return async (req, res) => { 7 | 8 | const unauthorizedResponse = () => { 9 | res 10 | .status(401) 11 | .set({ 12 | "Access-Control-Expose-Headers": "WWW-Authenticate", 13 | "WWW-Authenticate": `Bearer resource_metadata="http://localhost:3001/.well-known/oauth-protected-resource"`, 14 | }) 15 | .send(); 16 | } 17 | 18 | if (req.headers.authorization) { 19 | 20 | const parts = req.headers.authorization.split(" "); 21 | 22 | if (!/bearer/i.test(parts[0])) { 23 | unauthorizedResponse(); 24 | } else { 25 | 26 | // we need to pipe through the auth information to the MCP SDK which looks for an "auth" property 27 | const headerValue = req.headers.authorization.split(" "); 28 | if (headerValue[0] === "Bearer") { 29 | (req).auth = { 30 | token: headerValue[1], 31 | clientId, 32 | scopes: ["Files.ReadWrite.All", "Sites.Read.All"], 33 | }; 34 | } 35 | 36 | // we have the Bearer header in the req here - so we need to pipe it through 37 | wrapped(req, res, null); 38 | } 39 | 40 | } else { 41 | unauthorizedResponse(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/environment.ts: -------------------------------------------------------------------------------- 1 | import { stringIsNullOrEmpty } from "./utils.js"; 2 | 3 | function safeReadEnv(name: string, throwOnError = true): string { 4 | 5 | if (!stringIsNullOrEmpty(process.env[name])) { 6 | return process.env[name]; 7 | } 8 | 9 | if (throwOnError) { 10 | throw Error(`Could not read env property ${name}`); 11 | } 12 | } 13 | 14 | export const clientId = safeReadEnv("ODMCP_CLIENT_ID"); 15 | 16 | export const tenantId = safeReadEnv("ODMCP_TENANT_ID"); 17 | 18 | export const verbose = safeReadEnv("ODMCP_VERBOSE", false) || false; 19 | -------------------------------------------------------------------------------- /src/express-routes/file-stream.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "express"; 2 | import { combine, decodePathFromBase64, stringIsNullOrEmpty } from "../utils.js"; 3 | import { IncomingMessage } from "http"; 4 | import https from "https"; 5 | // import { getMethodContext } from "../method-context.js"; 6 | 7 | export function registerFileStreamRoutes(app: Application) { 8 | 9 | // suppport direct file stream access 10 | app.get(/^\/file\/(.*?)\/contentStream/, async (req, res) => { 11 | 12 | // the key is the path, but encoded 13 | const path = req.params[0]; 14 | 15 | // but first see if we have a key 16 | if (stringIsNullOrEmpty(path)) { 17 | res.status(400).send(JSON.stringify({ 18 | error: "Required file key was not supplied." 19 | })); 20 | return; 21 | } 22 | 23 | // get our normal context and token 24 | // const context = await getMethodContext(); 25 | // const token = await getToken(context); 26 | 27 | const token = "fake"; 28 | 29 | // decode the path should be a valid driveItem path 30 | const decodedPath = decodePathFromBase64(path); 31 | 32 | // here we need to stream the file and hope that path is real 33 | https.get(combine("https://graph.microsoft.com/v1.0", decodedPath, "contentStream"), { 34 | headers: { 35 | Authorization: `Bearer ${token}`, 36 | }, 37 | }, (upstreamRes: IncomingMessage) => { 38 | 39 | // Set the same headers for the downstream client 40 | res.writeHead(upstreamRes.statusCode || 500, upstreamRes.headers); 41 | 42 | // Pipe the upstream response directly to the downstream client 43 | upstreamRes.pipe(res); 44 | 45 | }).on('error', (err: Error) => { 46 | console.error('Error fetching upstream file:', err); 47 | res.writeHead(500); 48 | res.end('Internal Server Error'); 49 | }); 50 | 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/express-routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "express"; 2 | import { registerFileStreamRoutes } from "./file-stream.js"; 3 | import { registerOAuthRoutes } from "./oauth-resource.js" 4 | import { verbose } from "../environment.js"; 5 | 6 | export function registerRoutes(app: Application) { 7 | registerFileStreamRoutes(app); 8 | registerOAuthRoutes(app); 9 | 10 | if (verbose) { 11 | 12 | // just log all the request/response pairs 13 | app.use((req, res, next) => { 14 | console.log(req); 15 | console.log(res); 16 | next(); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/express-routes/oauth-resource.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "express"; 2 | import { clientId, tenantId } from "../environment.js"; 3 | 4 | /** 5 | * Registers the /.well-known/oauth-protected-resource route and response handler 6 | * 7 | * @param app the express application 8 | */ 9 | export function registerOAuthRoutes(app: Application) { 10 | 11 | app.options("/.well-known/oauth-protected-resource", function (req, res, next) { 12 | 13 | res 14 | .set({ 15 | "Access-Control-Allow-Origin": "*", 16 | "Access-Control-Allow-Methods": "GET,OPTIONS", 17 | "Access-Control-Allow-Headers": "Authorization", 18 | }) 19 | .send(200); 20 | }); 21 | 22 | app.get("/.well-known/oauth-protected-resource", function (req, res, next) { 23 | 24 | res 25 | .status(200) 26 | .set({ "Content-Type": "application/json", "Cache-Control": "no-store", "Pragma": "no-cache" }) 27 | .send({ 28 | resource_name: "Local testing files MCP Server", 29 | resource: "http://localhost:3001", 30 | authorization_servers: [`https://login.microsoftonline.com/${tenantId}/v2.0`], 31 | bearer_methods_supported: ["header"], 32 | scopes_supported: [ 33 | "openid", 34 | "profile", 35 | "email", 36 | "files.readwrite.all", 37 | "sites.read.all", 38 | // `VSCODE_TENANT:${tenantId}`, 39 | `VSCODE_CLIENT_ID:${clientId}`, 40 | ], 41 | issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`, 42 | }); 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // import { setupExpressServer } from "./setup-express-server-sse.js"; 2 | import { setupExpressServer } from "./setup-express-server-streamablehttp.js"; 3 | import { setupMCPServer } from "./setup-mcp-server.js"; 4 | 5 | async function main(): Promise { 6 | 7 | const server = await setupMCPServer(); 8 | const app = await setupExpressServer(server); 9 | 10 | const PORT = process.env.PORT || 3001; 11 | 12 | app.listen(PORT, () => { 13 | console.log(`Files MCP Server is running on port ${PORT}`); 14 | }); 15 | } 16 | 17 | main().catch((error) => { 18 | console.error("Fatal error in main():", error); 19 | process.exit(1); 20 | }); 21 | -------------------------------------------------------------------------------- /src/method-context.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "@modelcontextprotocol/sdk/types.js"; 2 | import { GenericPagedResponse, HandlerParams } from "./types.js"; 3 | import { combine, decodePathFromBase64, getNextCursor } from "./utils.js"; 4 | 5 | export interface MCPContext { 6 | fetch(path: string, init?: RequestInit, returnResponse?: boolean, augment?: (vals: T[]) => T[]): Promise; 7 | fetchAndAggregate(path: string, init?: RequestInit, augment?: (vals: T[]) => T[]): Promise 8 | graphBaseUrl: string; 9 | graphVersionPart: string; 10 | params: HandlerParams, 11 | } 12 | 13 | export async function getMethodContext(): Promise { 14 | 15 | // context passed to all tool/resource handlers 16 | return { 17 | graphBaseUrl: "https://graph.microsoft.com", 18 | graphVersionPart: "v1.0", 19 | async fetchAndAggregate(this: MCPContext, path: string, init?: RequestInit, augment?: (vals: T[]) => T[]): Promise { 20 | 21 | const resultAggregate = []; 22 | 23 | let response = await this.fetch(path, init); 24 | 25 | let [nextCursor] = getNextCursor(response); 26 | 27 | resultAggregate.push(...response.value); 28 | 29 | while (typeof nextCursor !== "undefined") { 30 | 31 | response = await this.fetch(decodePathFromBase64(nextCursor)); 32 | resultAggregate.push(...response.value); 33 | [nextCursor] = getNextCursor(response); 34 | } 35 | 36 | return typeof augment === "function" ? augment(resultAggregate) : resultAggregate; 37 | }, 38 | async fetch(this: MCPContext, path: string, init?: RequestInit, returnResponse = false, augment?: (vals: T) => T): Promise { 39 | 40 | const token = this.params.token!; 41 | 42 | const absPath = /https?:\/\//i.test(path) ? path : combine(this.graphBaseUrl, this.graphVersionPart, path); 43 | 44 | console.log(`FETCH PATH: ${absPath}`); 45 | 46 | const response = await fetch(absPath, { 47 | method: "GET", 48 | headers: { 49 | "Authorization": `Bearer ${token}`, 50 | }, 51 | ...init, 52 | }); 53 | 54 | if (!response.ok) { 55 | const txt = await response.text(); 56 | throw Error(`Error: ${txt}`); 57 | } 58 | 59 | if (returnResponse) { 60 | return response; 61 | } 62 | 63 | const json = await response.json(); 64 | 65 | return typeof augment === "function" ? augment(json) : json; 66 | }, 67 | params: {}, 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/resourceTemplates.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from "fs/promises"; 2 | import { DynamicResourceTemplate } from "./types.js"; 3 | import { resolve, dirname, parse } from "path"; 4 | import { fileURLToPath } from 'url'; 5 | import { ListResourceTemplatesRequest, ListResourceTemplatesResult, ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; 6 | import { MCPContext } from "./method-context.js"; 7 | 8 | const COMMON = "common"; 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | const resources = new Map(); 11 | 12 | export async function getResourceTemplates(): Promise> { 13 | 14 | if (resources.size < 1) { 15 | 16 | // Load tools from the tools directory 17 | const dirPath = resolve(__dirname, "resourceTemplates") 18 | const toolFiles = await readdir(dirPath); 19 | 20 | for (let i = 0; i < toolFiles.length; i++) { 21 | 22 | const toolFile = toolFiles[i]; 23 | 24 | if (/\.js$/.test(toolFile)) { 25 | 26 | resources.set(parse(toolFile).name, await import("file://" + resolve(dirPath, toolFile))); 27 | } 28 | } 29 | } 30 | 31 | return resources; 32 | } 33 | 34 | export async function getResourceTemplatesHandler(this: MCPContext): Promise { 35 | 36 | const { session } = this.params; 37 | 38 | const resources = await getResourceTemplates(); 39 | 40 | const usedResourcesP: Promise[] = []; 41 | 42 | for (let [key, resource] of resources) { 43 | if (key === COMMON || key === session.mode) { 44 | usedResourcesP.push(resource.publish.call(this)); 45 | } 46 | } 47 | 48 | const usedResources = (await Promise.all(usedResourcesP)).flat(2); 49 | 50 | return { 51 | resourceTemplates: usedResources.map(resource => ({ 52 | ...resource, 53 | })), 54 | }; 55 | } -------------------------------------------------------------------------------- /src/resourceTemplates/common.ts: -------------------------------------------------------------------------------- 1 | import { ListResourceTemplatesRequest, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../method-context.js"; 3 | 4 | export async function publish(this: MCPContext): Promise { 5 | 6 | return [ 7 | { 8 | uriTemplate: "error://{error-path}", 9 | name: "Provides additional information about the error codes this server returns.", 10 | description: "You can use these resources any time to lookup more information about errors this server returns. They will all start with the error:// protocol.", 11 | mimeType: "application/json", 12 | }, 13 | { 14 | uriTemplate: "file://{file-key}", 15 | name: "Gets a file's metadata by its key", 16 | description: "Allows you to reference a site entity by key, replacing {site-key} with a valid file key. You can get a listing of file keys by listing the file resources in a library", 17 | }, 18 | { 19 | uriTemplate: "folder://{folder-key}", 20 | name: "Gets a folder's metadata by its key", 21 | description: "Allows you to reference a folder entity by key, replacing {folder-key} with a valid sitekey.", 22 | }, 23 | { 24 | uriTemplate: "library://{library-key}", 25 | name: "Gets a library's metadata by its key", 26 | description: "Allows you to reference a library entity by key, replacing {library-key} with a valid sitekey.", 27 | }, 28 | { 29 | uriTemplate: "site://{site-key}", 30 | name: "Gets a site's metadata by its key", 31 | description: "Allows you to reference a site entity by key, replacing {site-key} with a valid site key.", 32 | mimeType: "application/json", 33 | }, 34 | { 35 | uriTemplate: "list://{list-key}", 36 | name: "Gets a list's metadata by its key", 37 | description: "Allows you to reference a site entity bykey, replacing {list-key} with a valid site key.", 38 | mimeType: "application/json", 39 | }, 40 | { 41 | uriTemplate: "listitem://{listitem-key}", 42 | name: "Gets a list item's metadata by its key", 43 | description: "Allows you to reference a site entity bykey, replacing {site-key} with a valid sitekey.", 44 | mimeType: "application/json", 45 | }, 46 | ]; 47 | } 48 | 49 | -------------------------------------------------------------------------------- /src/resourceTemplates/file.ts: -------------------------------------------------------------------------------- 1 | import { ListResourceTemplatesRequest, ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MCPContext } from "../method-context.js"; 3 | 4 | export async function publish(this: MCPContext): Promise { 5 | 6 | return []; 7 | } -------------------------------------------------------------------------------- /src/resourceTemplates/site.ts: -------------------------------------------------------------------------------- 1 | import { ListResourceTemplatesRequest, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../method-context.js"; 3 | 4 | export async function publish(this: MCPContext): Promise { 5 | 6 | return []; 7 | } 8 | -------------------------------------------------------------------------------- /src/resources.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from "fs/promises"; 2 | import { DynamicResource, DynamicToolMode, COMMON } from "./types.js"; 3 | import { resolve, dirname, parse } from "path"; 4 | import { fileURLToPath } from 'url'; 5 | import { ListResourcesRequest, ListResourcesResult, ReadResourceRequest, Resource } from "@modelcontextprotocol/sdk/types.js"; 6 | import { MCPContext } from "./method-context.js"; 7 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | const resources = new Map(); 11 | 12 | export async function clearResourcesCache(server: Server): Promise { 13 | 14 | await server.notification({ 15 | method: "notifications/resources/list_changed", 16 | }); 17 | 18 | resources.clear(); 19 | } 20 | 21 | export async function getResources(): Promise> { 22 | 23 | resources.clear(); 24 | 25 | if (resources.size < 1) { 26 | 27 | // Load tools from the tools directory 28 | const dirPath = resolve(__dirname, "resources") 29 | const resourceFiles = await readdir(dirPath, { recursive: false }); 30 | 31 | for (let i = 0; i < resourceFiles.length; i++) { 32 | 33 | const resourceFile = resourceFiles[i]; 34 | 35 | if (/\.js$/.test(resourceFile)) { 36 | 37 | resources.set(parse(resourceFile).name, await import("file://" + resolve(dirPath, resourceFile))); 38 | } 39 | } 40 | } 41 | 42 | return resources; 43 | } 44 | 45 | export async function getResourcesHandler(this: MCPContext): Promise { 46 | 47 | const { session } = this.params; 48 | 49 | const resources = await getResources(); 50 | 51 | const activeResources: Promise[] = []; 52 | 53 | for (let [key, resource] of resources) { 54 | if (key === COMMON || key === session.mode) { 55 | activeResources.push(resource.publish.call(this)); 56 | } 57 | } 58 | 59 | const exposedResources = (await Promise.all(activeResources)).flat(2); 60 | 61 | return { 62 | resources: exposedResources.map(resource => ({ 63 | ...resource, 64 | })), 65 | }; 66 | } 67 | 68 | export async function readResourceHandler(this: MCPContext) { 69 | 70 | const resources = await getResources(); 71 | 72 | const { request } = this.params; 73 | 74 | const uri = new URL(request.params.uri); 75 | const mode = uri.protocol.replace(/:$/, ""); 76 | 77 | try { 78 | 79 | if (resources.has(mode)) { 80 | return resources.get(mode).handler.call(this); 81 | } else { 82 | return resources.get(COMMON).handler.call(this); 83 | } 84 | 85 | } catch (e) { 86 | console.error(e); 87 | } 88 | 89 | throw new Error(`Unknown resource: ${uri.toString()}`); 90 | } 91 | -------------------------------------------------------------------------------- /src/resources/common.ts: -------------------------------------------------------------------------------- 1 | import { ReadResourceRequest, ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../method-context.js"; 3 | import { ResourceReadHandlerMap } from "../types.js"; 4 | import { processResourceHandlers } from "./core/process-resource-handlers.js"; 5 | 6 | export async function publish(this: MCPContext): Promise<(Resource | ResourceTemplate)[]> { 7 | 8 | return []; 9 | } 10 | 11 | export async function handler(this: MCPContext): Promise { 12 | 13 | const { request } = this.params; 14 | 15 | const uri = new URL(request.params.uri); 16 | 17 | if (!/^error:$|^common:$/i.test(uri.protocol)) { 18 | // filter by all the protocols this handler can accept 19 | // this was misrouted, maybe something else will pick it up 20 | return; 21 | } 22 | 23 | return processResourceHandlers.call(this, uri, handlers); 24 | } 25 | 26 | /** 27 | * This is a map of [function, handler] tuples. If the function returns true, the handler is used. Multiple can apply 28 | */ 29 | const handlers: ResourceReadHandlerMap = new Map([ 30 | [ 31 | // handle any file based protocol with default handlers 32 | (uri) => /^common:$/i.test(uri.protocol), 33 | async function (this: MCPContext, uri: URL): Promise { 34 | 35 | const resources: Resource[] = []; 36 | 37 | resources.push({ 38 | uri: uri.toString(), 39 | name: uri.host, 40 | mimeType: "text/plain", 41 | text: `This is a common resource with uri ${uri.toString()}`, 42 | }); 43 | 44 | return resources; 45 | }, 46 | ], 47 | [ 48 | // handle any file based protocol with default handlers 49 | (uri) => /^error:$/i.test(uri.protocol), 50 | async function (this: MCPContext, uri: URL): Promise { 51 | 52 | const resources: Resource[] = []; 53 | 54 | if (errorMap.has(uri.host)) { 55 | 56 | resources.push({ 57 | uri: uri.toString(), 58 | name: uri.host, 59 | ...errorMap.get(uri.host) 60 | }); 61 | } 62 | 63 | return resources; 64 | }, 65 | ], 66 | ]); 67 | 68 | const errorMap = new Map( 69 | [ 70 | [ 71 | "resource-not-found", 72 | { 73 | mimeType: "text/plain", 74 | text: "This error indicates we were unable to locate the resource defined by the uri.", 75 | } 76 | ], 77 | [ 78 | "file-map-error", 79 | { 80 | mimeType: "text/plain", 81 | text: "This error indicates we were unable to map a file response to an appropriate resource entry.", 82 | } 83 | ] 84 | ]) 85 | -------------------------------------------------------------------------------- /src/resources/core/default-resource-handler.ts: -------------------------------------------------------------------------------- 1 | import { MCPContext } from "../../method-context.js"; 2 | import { DynamicToolMode, ResourceReadHandler, ResourceReadHandlerTest } from "../../types.js"; 3 | import { ReadResourceRequest, Resource } from "@modelcontextprotocol/sdk/types.js"; 4 | import { decodePathFromBase64 } from "../../utils.js"; 5 | 6 | export function getDefaultResourceHandlerMapEntryFor(protocol: Exclude): [ResourceReadHandlerTest, ResourceReadHandler] { 7 | 8 | return [ 9 | (uri) => RegExp(`^${protocol}:`, "i").test(uri.protocol), 10 | async function (this: MCPContext, uri: URL): Promise { 11 | 12 | const { request } = this.params; 13 | 14 | const resources: Resource[] = []; 15 | 16 | const path = decodePathFromBase64(request.params.uri.replace(RegExp(`^${protocol}:\/\/`, "i"), "")); 17 | 18 | const result = await this.fetch(path); 19 | 20 | resources.push({ 21 | uri: request.params.uri, 22 | mimeType: "application/json", 23 | text: JSON.stringify(result, null, 2), 24 | }); 25 | 26 | if (resources.length < 1) { 27 | 28 | resources.push({ 29 | uri: request.params.uri, 30 | text: `Could not read ${protocol} ${uri.host}`, 31 | isError: true, 32 | }); 33 | } 34 | 35 | return resources; 36 | }, 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/resources/core/process-resource-handlers.ts: -------------------------------------------------------------------------------- 1 | import { ReadResourceRequest, ReadResourceResult, Resource } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../../method-context.js"; 3 | import { ResourceReadHandlerMap, ResourceReadHandlerResult } from "../../types.js"; 4 | 5 | // this method allows multiple handlers to run and aggregates the results 6 | // that's a good pattern, but doesn't work well with nextCursor - or we would need a compound next cursor which seems overly complicated 7 | export async function processResourceHandlers(this: MCPContext, uri: URL, handlers: ResourceReadHandlerMap): Promise { 8 | 9 | const resourcePromises: Promise[] = []; 10 | 11 | for (let [test, func] of handlers) { 12 | 13 | if (test.call(this, uri)) { 14 | resourcePromises.push(func.call(this, uri)); 15 | break; 16 | } 17 | } 18 | 19 | const resources = (await Promise.all(resourcePromises)).flat(); 20 | 21 | if (resources.length < 1) { 22 | 23 | resources.push({ 24 | uri: "error://resource-not-found", 25 | mimeType: "application/json", 26 | text: JSON.stringify({ error: `Resource could not be located in common for uri ${uri.toString()}.` }), 27 | }); 28 | } 29 | 30 | return { 31 | contents: resources, 32 | }; 33 | } 34 | 35 | // this take the first handler result returned, making ordering important in the array of resource handlers 36 | export async function processResourceHandlers_single(this: MCPContext, uri: URL, handlers: ResourceReadHandlerMap): Promise { 37 | 38 | const handler = handlers.entries().find(h => { 39 | return h[0].call(this, uri); 40 | }); 41 | 42 | if (typeof handler !== "undefined") { 43 | 44 | const result: ResourceReadHandlerResult = await handler[1].call(this, uri); 45 | 46 | if (Array.isArray(result)) { 47 | 48 | return { 49 | uri: uri.toString(), 50 | contents: result, 51 | }; 52 | 53 | } else { 54 | 55 | return { 56 | uri: uri.toString(), 57 | contents: result.resources, 58 | nextCursor: result.nextCursor, 59 | }; 60 | } 61 | } 62 | 63 | return { 64 | contents: [{ 65 | uri: "error://resource-not-found", 66 | mimeType: "application/json", 67 | text: JSON.stringify({ error: `Resource could not be located in common for uri ${uri.toString()}.` }), 68 | }], 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/resources/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from "@modelcontextprotocol/sdk/types"; 2 | import { encodePathToBase64 } from "../../utils.js"; 3 | 4 | export function mapDriveItemResponseToResource(driveItemResponse: { id: string, name: string, file: { mimeType: string }, parentReference: { driveId: string, id: string } }): Resource { 5 | 6 | return { 7 | uri: createDriveItemResourceKey(driveItemResponse), 8 | name: driveItemResponse.name, 9 | mimeType: driveItemResponse.file.mimeType, 10 | } 11 | } 12 | 13 | export function createDriveItemResourceKey(driveItemResponse: { id: string, name: string, file: { mimeType: string }, parentReference: { driveId: string, id: string } }): string { 14 | 15 | const fileBase = `/drives/${driveItemResponse.parentReference.driveId}/items/${driveItemResponse.id}`; 16 | 17 | return `file://${encodePathToBase64(fileBase)}`; 18 | } 19 | 20 | export function createSiteResourceKey(siteResponse: { id: string }): string { 21 | 22 | const fileBase = `/sites/${siteResponse.id}`; 23 | 24 | return `site://${encodePathToBase64(fileBase)}`; 25 | } 26 | 27 | -------------------------------------------------------------------------------- /src/resources/file.ts: -------------------------------------------------------------------------------- 1 | import { ReadResourceRequest, ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../method-context.js"; 3 | import { processResourceHandlers } from "./core/process-resource-handlers.js"; 4 | import { ResourceReadHandlerMap } from "../types.js"; 5 | import { combine, decodePathFromBase64, encodePathToBase64 } from "../utils.js"; 6 | import { mapDriveItemResponseToResource } from "./core/utils.js"; 7 | import { getDefaultResourceHandlerMapEntryFor } from "./core/default-resource-handler.js"; 8 | 9 | export async function publish(this: MCPContext): Promise<(Resource | ResourceTemplate)[]> { 10 | 11 | const { session } = this.params; 12 | 13 | // for file let's just grab some things and create resoureces so they are available 14 | 15 | const resources: Resource[] = []; 16 | 17 | // include metadata 18 | if (session.mode === "file") { 19 | 20 | const metadata = await this.fetch(session.currentContextRoot); 21 | const resource = mapDriveItemResponseToResource(metadata); 22 | const key = encodePathToBase64(session.currentContextRoot); 23 | 24 | resources.push( 25 | 26 | // expose metadata resource of file 27 | resource, 28 | 29 | // expose direct download 30 | { 31 | uri: combine("/", "file", key, "contentStream"), 32 | name: `Directly download the content of the file: ${resource.name}`, 33 | description: "This resources represents a direct download of the file. To access it do not sent a resource request, instead make a request using the supplied path to the server. You should include authentication information as normal.", 34 | mimeType: resource.mimeType, 35 | }); 36 | } 37 | 38 | return resources; 39 | } 40 | 41 | export async function handler(this: MCPContext): Promise { 42 | 43 | const { request } = this.params; 44 | 45 | const uri = new URL(request.params.uri); 46 | 47 | if (!/^file:$/i.test.call(this, uri.protocol)) { 48 | // filter by all the protocols this handler can accept 49 | // this was misrouted, maybe something elese will pick it up 50 | return; 51 | } 52 | 53 | return processResourceHandlers.call(this, uri, handlers); 54 | } 55 | 56 | /** 57 | * This is a map of [function, handler] tuples. If the function returns true, the handler is used. 58 | */ 59 | const handlers: ResourceReadHandlerMap = new Map([ 60 | [ 61 | (uri) => /^file:\/\/.*?\/content$/i.test(uri.toString()), 62 | async function (this: MCPContext, uri: URL): Promise { 63 | 64 | const { request } = this.params; 65 | const encodedPath = /^file:\/\/(.*?)\/content$/.exec(request.params.uri); 66 | const path = decodePathFromBase64(encodedPath[1]); 67 | 68 | const result = await this.fetch(combine(path, "contentStream"), { 69 | responseType: "arraybuffer", 70 | }, true); 71 | 72 | const mimeType = result.headers.get("Content-Type"); 73 | 74 | if (mimeType === "text/plain") { 75 | 76 | const text = await result.text(); 77 | 78 | return [{ 79 | uri: request.params.uri, 80 | mimeType, 81 | text, 82 | }]; 83 | 84 | } else { 85 | 86 | const buffer = await result.arrayBuffer(); 87 | 88 | return [{ 89 | uri: request.params.uri, 90 | mimeType: mimeType || "application/octet-stream", 91 | blob: Buffer.from(buffer).toString("base64"), 92 | }]; 93 | } 94 | } 95 | ], 96 | getDefaultResourceHandlerMapEntryFor("file"), 97 | ]); 98 | -------------------------------------------------------------------------------- /src/resources/folder.ts: -------------------------------------------------------------------------------- 1 | // resources of a folder are files, size info 2 | 3 | import { ReadResourceRequest, ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 4 | import { MCPContext } from "../method-context.js"; 5 | import { ResourceReadHandlerMap } from "../types.js"; 6 | import { processResourceHandlers } from "./core/process-resource-handlers.js"; 7 | import { getDefaultResourceHandlerMapEntryFor } from "./core/default-resource-handler.js"; 8 | 9 | export async function publish(this: MCPContext): Promise<(Resource | ResourceTemplate)[]> { 10 | 11 | return []; 12 | } 13 | 14 | export async function handler(this: MCPContext): Promise { 15 | 16 | const { request } = this.params; 17 | 18 | const uri = new URL(request.params.uri); 19 | 20 | if (!/^folder:$/i.test.call(this, uri.protocol)) { 21 | // filter by all the protocols this handler can accept 22 | // this was misrouted, maybe something elese will pick it up 23 | return; 24 | } 25 | 26 | return processResourceHandlers.call(this, uri, handlers); 27 | } 28 | 29 | 30 | /** 31 | * This is a map of [function, handler] tuples. If the function returns true, the handler is used. 32 | */ 33 | const handlers: ResourceReadHandlerMap = new Map([ 34 | getDefaultResourceHandlerMapEntryFor("folder"), 35 | ]); 36 | -------------------------------------------------------------------------------- /src/resources/library.ts: -------------------------------------------------------------------------------- 1 | import { ReadResourceRequest, ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../method-context.js"; 3 | import { ResourceReadHandlerMap } from "../types.js"; 4 | import { combine } from "../utils.js"; 5 | import { mapDriveItemResponseToResource } from "./core/utils.js"; 6 | import { processResourceHandlers } from "./core/process-resource-handlers.js"; 7 | import { getDefaultResourceHandlerMapEntryFor } from "./core/default-resource-handler.js"; 8 | 9 | export async function publish(this: MCPContext): Promise<(Resource | ResourceTemplate)[]> { 10 | 11 | const { session } = this.params; 12 | 13 | const resources = []; 14 | 15 | // add recent files in context of drives 16 | if (session.mode === "consumerOD" || session.mode === "library") { 17 | 18 | // in the context of a drive, we can list all the files as resources - which will take a bit. 19 | // so instead what if we list the most recent files in that drive? 20 | try { 21 | const recentFiles = await this.fetch(combine(session.currentContextRoot, "recent")); 22 | 23 | resources.push(...recentFiles.map(mapDriveItemResponseToResource)); 24 | 25 | } catch (e) { 26 | 27 | // this only works for delegated so for now it will just fail 28 | console.error(e); 29 | } 30 | } 31 | 32 | return resources; 33 | } 34 | 35 | export async function handler(this: MCPContext): Promise { 36 | 37 | const { request } = this.params; 38 | 39 | const uri = new URL(request.params.uri); 40 | 41 | if (!/^library:$/i.test(uri.protocol)) { 42 | // filter by all the protocols this handler can accept 43 | // this was misrouted, maybe something elese will pick it up 44 | return; 45 | } 46 | 47 | return processResourceHandlers.call(this, uri, handlers); 48 | } 49 | 50 | 51 | /** 52 | * This is a map of [function, handler] tuples. If the function returns true, the handler is used. 53 | */ 54 | const handlers: ResourceReadHandlerMap = new Map([ 55 | getDefaultResourceHandlerMapEntryFor("library"), 56 | ]); 57 | -------------------------------------------------------------------------------- /src/resources/list.ts: -------------------------------------------------------------------------------- 1 | import { ReadResourceRequest, ReadResourceResult, Resource, ResourceTemplate } from "@modelcontextprotocol/sdk/types"; 2 | import { MCPContext } from "../method-context.js"; 3 | import { ResourceReadHandlerMap } from "../types.js"; 4 | import { getDefaultResourceHandlerMapEntryFor } from "./core/default-resource-handler.js"; 5 | import { processResourceHandlers } from "./core/process-resource-handlers.js"; 6 | 7 | export async function publish(this: MCPContext): Promise<(Resource | ResourceTemplate)[]> { 8 | 9 | return []; 10 | } 11 | 12 | export async function handler(this: MCPContext): Promise { 13 | 14 | const { request } = this.params; 15 | 16 | const uri = new URL(request.params.uri); 17 | 18 | if (!/^list:$/i.test(uri.protocol)) { 19 | // filter by all the protocols this handler can accept 20 | // this was misrouted, maybe something elese will pick it up 21 | return; 22 | } 23 | 24 | return processResourceHandlers.call(this, uri, handlers); 25 | } 26 | 27 | 28 | /** 29 | * This is a map of [function, handler] tuples. If the function returns true, the handler is used. 30 | */ 31 | const handlers: ResourceReadHandlerMap = new Map([ 32 | getDefaultResourceHandlerMapEntryFor("list"), 33 | ]); 34 | 35 | -------------------------------------------------------------------------------- /src/resources/site.ts: -------------------------------------------------------------------------------- 1 | import { Resource, ReadResourceResult, ReadResourceRequest } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MCPContext } from "../method-context.js"; 3 | import { ResourceReadHandlerMap } from "../types.js"; 4 | import { processResourceHandlers } from "./core/process-resource-handlers.js"; 5 | import { getDefaultResourceHandlerMapEntryFor } from "./core/default-resource-handler.js"; 6 | 7 | export async function publish(this: MCPContext): Promise { 8 | 9 | return []; 10 | } 11 | 12 | export async function handler(this: MCPContext): Promise { 13 | 14 | const { request } = this.params; 15 | 16 | const uri = new URL(request.params.uri); 17 | 18 | if (!/^site:$/i.test.call(this, uri.protocol)) { 19 | // filter by all the protocols this handler can accept 20 | // this was misrouted, maybe something elese will pick it up 21 | return; 22 | } 23 | 24 | return processResourceHandlers.call(this, uri, handlers); 25 | } 26 | 27 | 28 | /** 29 | * This is a map of [function, handler] tuples. If the function returns true, the handler is used. 30 | */ 31 | const handlers: ResourceReadHandlerMap = new Map([ 32 | getDefaultResourceHandlerMapEntryFor("site"), 33 | ]); 34 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import { DynamicToolMode } from "./types.js"; 2 | import { stringIsNullOrEmpty } from "./utils.js"; 3 | 4 | // amazing session management 5 | const sessions = new Map(); 6 | 7 | export interface MCPSession { 8 | sessionId: string; 9 | mode: DynamicToolMode; 10 | currentContextRoot: string; 11 | props: Record; 12 | } 13 | 14 | export async function ensureSession(sessionId: string): Promise { 15 | 16 | if (stringIsNullOrEmpty(sessionId)) { 17 | throw Error("No Session id."); 18 | } 19 | 20 | if (sessions.has(sessionId)) { 21 | return sessions.get(sessionId); 22 | } 23 | 24 | const sessionCtx: MCPSession = { 25 | sessionId, 26 | currentContextRoot: "", 27 | mode: "not-set", 28 | props: {}, 29 | } 30 | 31 | sessions.set(sessionId, sessionCtx); 32 | 33 | return sessionCtx; 34 | } 35 | 36 | export async function patchSession(sessionId, session: Partial>): Promise { 37 | 38 | if (stringIsNullOrEmpty(sessionId)) { 39 | throw Error("No Session id."); 40 | } 41 | 42 | if (!sessions.has(sessionId)) { 43 | return ensureSession(sessionId); 44 | } 45 | 46 | const currentSession = sessions.get(sessionId); 47 | 48 | sessions.set(sessionId, { ...currentSession, ...session }); 49 | 50 | return sessions.get(sessionId); 51 | } 52 | -------------------------------------------------------------------------------- /src/setup-express-server-sse.ts: -------------------------------------------------------------------------------- 1 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import express from "express"; 4 | import { requireAuthentication } from "./auth.js"; 5 | import { registerRoutes } from "./express-routes/index.js"; 6 | 7 | export async function setupExpressServer(server: Server) { 8 | 9 | let transport: SSEServerTransport; 10 | 11 | const app = express(); 12 | 13 | registerRoutes(app); 14 | 15 | app.get("/sse", requireAuthentication(async (_req, res) => { 16 | 17 | console.log("Received connection"); 18 | transport = new SSEServerTransport("/message", res); 19 | 20 | await server.connect(transport); 21 | 22 | server.onclose = async () => { 23 | await server.close(); 24 | }; 25 | })); 26 | 27 | app.post("/message", requireAuthentication(async (req, res) => transport.handlePostMessage(req, res))); 28 | 29 | // just catch stuff for debugging to see if clients are calling in ways we don't handle. 30 | app.all(/.*/, (req, res) => { 31 | 32 | const reqPath = req.path.toString(); 33 | 34 | console.warn(`Unhandled path: ${req.method} ${reqPath}`); 35 | res.setHeader('Content-Type', 'application/json') 36 | res.status(404); 37 | res.send({ error: `${reqPath} not found.` }); 38 | }); 39 | 40 | return app; 41 | } 42 | -------------------------------------------------------------------------------- /src/setup-express-server-streamablehttp.ts: -------------------------------------------------------------------------------- 1 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import express from "express"; 4 | import { InMemoryEventStore } from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js'; 5 | import { requireAuthentication } from "./auth.js"; 6 | import { registerRoutes } from "./express-routes/index.js"; 7 | import { randomUUID } from 'node:crypto'; 8 | 9 | export async function setupExpressServer(server: Server) { 10 | 11 | const app = express(); 12 | 13 | registerRoutes(app); 14 | 15 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; 16 | 17 | app.post('/mcp', requireAuthentication(async (req, res) => { 18 | 19 | console.log('Received MCP POST request'); 20 | 21 | try { 22 | 23 | // Check for existing session ID 24 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 25 | let transport: StreamableHTTPServerTransport; 26 | 27 | if (sessionId && transports[sessionId]) { 28 | // Reuse existing transport 29 | transport = transports[sessionId]; 30 | } else if (!sessionId) { 31 | // New initialization request 32 | const eventStore = new InMemoryEventStore(); 33 | transport = new StreamableHTTPServerTransport({ 34 | sessionIdGenerator: () => randomUUID(), 35 | eventStore, // Enable resumability 36 | onsessioninitialized: (sessionId) => { 37 | // Store the transport by session ID when session is initialized 38 | // This avoids race conditions where requests might come in before the session is stored 39 | console.log(`Session initialized with ID: ${sessionId}`); 40 | transports[sessionId] = transport; 41 | } 42 | }); 43 | 44 | // Set up onclose handler to clean up transport when closed 45 | transport.onclose = () => { 46 | const sid = transport.sessionId; 47 | if (sid && transports[sid]) { 48 | console.log(`Transport closed for session ${sid}, removing from transports map`); 49 | delete transports[sid]; 50 | } 51 | }; 52 | 53 | // Connect the transport to the MCP server BEFORE handling the request 54 | // so responses can flow back through the same transport 55 | await server.connect(transport); 56 | 57 | await transport.handleRequest(req, res); 58 | 59 | return; // Already handled 60 | 61 | } else { 62 | 63 | // Invalid request - no session ID or not initialization request 64 | res.status(400).json({ 65 | jsonrpc: '2.0', 66 | error: { 67 | code: -32000, 68 | message: 'Bad Request: No valid session ID provided', 69 | }, 70 | id: req?.body?.id, 71 | }); 72 | 73 | return; 74 | } 75 | 76 | // Handle the request with existing transport - no need to reconnect 77 | // The existing transport is already connected to the server 78 | await transport.handleRequest(req, res); 79 | 80 | } catch (error) { 81 | 82 | console.error('Error handling MCP request:', error); 83 | if (!res.headersSent) { 84 | res.status(500).json({ 85 | jsonrpc: '2.0', 86 | error: { 87 | code: -32603, 88 | message: 'Internal server error', 89 | }, 90 | id: req?.body?.id, 91 | }); 92 | return; 93 | } 94 | } 95 | 96 | })); 97 | 98 | 99 | // Handle GET requests for SSE streams (using built-in support from StreamableHTTP) 100 | app.get('/mcp', requireAuthentication(async (req, res) => { 101 | 102 | console.log('Received MCP GET request'); 103 | 104 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 105 | 106 | if (!sessionId || !transports[sessionId]) { 107 | res.status(400).json({ 108 | jsonrpc: '2.0', 109 | error: { 110 | code: -32000, 111 | message: 'Bad Request: No valid session ID provided', 112 | }, 113 | id: req?.body?.id, 114 | }); 115 | return; 116 | } 117 | 118 | // Check for Last-Event-ID header for resumability 119 | const lastEventId = req.headers['last-event-id'] as string | undefined; 120 | if (lastEventId) { 121 | console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); 122 | } else { 123 | console.log(`Establishing new SSE stream for session ${sessionId}`); 124 | } 125 | 126 | const transport = transports[sessionId]; 127 | await transport.handleRequest(req, res); 128 | })); 129 | 130 | // Handle DELETE requests for session termination (according to MCP spec) 131 | app.delete('/mcp', requireAuthentication(async (req, res) => { 132 | 133 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 134 | if (!sessionId || !transports[sessionId]) { 135 | res.status(400).json({ 136 | jsonrpc: '2.0', 137 | error: { 138 | code: -32000, 139 | message: 'Bad Request: No valid session ID provided', 140 | }, 141 | id: req?.body?.id, 142 | }); 143 | return; 144 | } 145 | 146 | console.log(`Received session termination request for session ${sessionId}`); 147 | 148 | try { 149 | const transport = transports[sessionId]; 150 | await transport.handleRequest(req, res); 151 | } catch (error) { 152 | console.error('Error handling session termination:', error); 153 | if (!res.headersSent) { 154 | res.status(500).json({ 155 | jsonrpc: '2.0', 156 | error: { 157 | code: -32603, 158 | message: 'Error handling session termination', 159 | }, 160 | id: req?.body?.id, 161 | }); 162 | return; 163 | } 164 | } 165 | })); 166 | 167 | // Handle server shutdown 168 | process.on('SIGINT', async () => { 169 | 170 | console.log('Shutting down server...'); 171 | 172 | // Close all active transports to properly clean up resources 173 | for (const sessionId in transports) { 174 | try { 175 | console.log(`Closing transport for session ${sessionId}`); 176 | await transports[sessionId].close(); 177 | delete transports[sessionId]; 178 | } catch (error) { 179 | console.error(`Error closing transport for session ${sessionId}:`, error); 180 | } 181 | } 182 | await server.close(); 183 | console.log('Server shutdown complete'); 184 | }); 185 | 186 | // just catch stuff for debugging to see if clients are calling in ways we don't handle. 187 | app.all(/.*/, (req, res) => { 188 | 189 | const reqPath = req.path.toString(); 190 | 191 | console.warn(`Unhandled path: ${req.method} ${reqPath}`); 192 | res.setHeader('Content-Type', 'application/json') 193 | res.status(404); 194 | res.send({ error: `${reqPath} not found.` }); 195 | }); 196 | 197 | return app; 198 | } 199 | -------------------------------------------------------------------------------- /src/setup-mcp-server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { 3 | CallToolRequestSchema, 4 | ListResourcesRequestSchema, 5 | ListResourceTemplatesRequestSchema, 6 | ListToolsRequestSchema, 7 | Notification, 8 | ReadResourceRequestSchema, 9 | Request, 10 | } from "@modelcontextprotocol/sdk/types.js"; 11 | import { callToolHandler, getToolsHandler } from "./tools.js"; 12 | import { MCPContext } from "./method-context.js"; 13 | import { getResourcesHandler, readResourceHandler } from "./resources.js"; 14 | import { getResourceTemplatesHandler } from "./resourceTemplates.js"; 15 | import { ensureSession } from "./session.js"; 16 | import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; 17 | import { getMethodContext } from "./method-context.js"; 18 | 19 | export async function setupMCPServer(): Promise { 20 | 21 | // setup the server 22 | const server = new Server( 23 | { 24 | name: "Files-MCP-Server", 25 | version: "0.0.1", 26 | }, 27 | { 28 | capabilities: { 29 | tools: { 30 | listChanged: true, 31 | }, 32 | resources: { 33 | listChanged: true, 34 | }, 35 | }, 36 | instructions: `This server represents a local testing experience for working with Microsoft OneDrive and SharePoint resources. It exposes a set of contextual tools and resources aligned 37 | to each of the entities - sites, libraries, lists, files, list items, and folders. Each of these entities can be addressed as a resource with a specific protocol and key which 38 | forms a tuple identifying that entity. The key is generated by this server, and is not equivelent to the id value in the metadata. 39 | 40 | At any time you can change context using the set_context tool - which accepts most SharePoint and OneDrive urls and resource uris created by this server. Setting context 41 | will expose a new set of tools/resources available in that context. If no context is set you can list the available sites and drives.`, 42 | } 43 | ); 44 | 45 | // this function allows us to normalize the paramters for handlers, injecting anything we need centrally 46 | function callHandler Promise>(handler: T) { 47 | 48 | return async (request: Request, extra: RequestHandlerExtra): Promise> => { 49 | 50 | // create a new context "this" instance per-request. This allows new props/methods to be attached to the context without affecting global 51 | const context = await getMethodContext(); 52 | const session = await ensureSession(extra.sessionId); 53 | 54 | context.params = { 55 | server, 56 | request, 57 | extra, 58 | session, 59 | token: extra?.authInfo?.token || "", 60 | }; 61 | 62 | return handler.call(context); 63 | } 64 | } 65 | 66 | // this allows us to list tools 67 | server.setRequestHandler(ListToolsRequestSchema, callHandler(getToolsHandler)); 68 | 69 | // this handles individual tool requests, mapping them to the appropriate tool 70 | server.setRequestHandler(CallToolRequestSchema, callHandler(callToolHandler)); 71 | 72 | // this allows us to list resources 73 | server.setRequestHandler(ListResourcesRequestSchema, callHandler(getResourcesHandler)); 74 | 75 | // and read a resource 76 | server.setRequestHandler(ReadResourceRequestSchema, callHandler(readResourceHandler)); 77 | 78 | // list all the resource templates 79 | server.setRequestHandler(ListResourceTemplatesRequestSchema, callHandler(getResourceTemplatesHandler)); 80 | 81 | return server; 82 | } 83 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from "fs/promises"; 2 | import { COMMON, DynamicTool } from "./types.js"; 3 | import { resolve, dirname } from "path"; 4 | import { fileURLToPath } from 'url'; 5 | import { CallToolRequest, ListToolsRequest, ListToolsResult } from "@modelcontextprotocol/sdk/types.js"; 6 | import { MCPContext } from "./method-context.js"; 7 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 8 | import { formatCallToolResult } from "./tools/core/utils.js"; 9 | 10 | const __dirname = dirname(fileURLToPath(import.meta.url)); 11 | const tools = []; 12 | 13 | export async function clearToolsCache(server: Server): Promise { 14 | 15 | await server.notification({ 16 | method: "notifications/tools/list_changed", 17 | }); 18 | 19 | tools.length = 0; 20 | } 21 | 22 | export async function getTools(): Promise { 23 | 24 | if (tools.length < 1) { 25 | 26 | // Load tools from the tools directory 27 | const dirPath = resolve(__dirname, "tools"); 28 | const allFiles = await readdir(dirPath, { recursive: false }); 29 | const toolFiles = allFiles.filter(f => /\.js$/.test(f)) 30 | 31 | for (let i = 0; i < toolFiles.length; i++) { 32 | 33 | const toolFile = toolFiles[i]; 34 | try { 35 | tools.push(await import("file://" + resolve(dirPath, toolFile))); 36 | } catch (e) { 37 | console.error(e); 38 | } 39 | } 40 | } 41 | 42 | return tools; 43 | } 44 | 45 | export async function getToolsHandler(this: MCPContext): Promise { 46 | 47 | const { session } = this.params; 48 | 49 | const tools = await getTools(); 50 | 51 | return { 52 | tools: tools.filter(t => t.modes.includes(COMMON) || t.modes.includes(session.mode)).map(tool => ( 53 | { 54 | // include default empty input schema, required by some clients 55 | inputSchema: { 56 | type: "object", 57 | properties: {}, 58 | required: [], 59 | }, 60 | ...tool, 61 | })), 62 | }; 63 | } 64 | 65 | export async function callToolHandler(this: MCPContext) { 66 | 67 | const { request } = this.params; 68 | 69 | const tools = await getTools(); 70 | 71 | try { 72 | 73 | const tool = tools.filter(t => t.name === request.params.name); 74 | if (tool.length < 1) { 75 | throw Error(`Could not locate tool "${request.params.name}".`); 76 | } 77 | 78 | return tool[0].handler.call(this); 79 | 80 | } catch (e) { 81 | 82 | console.error("Fatal error in calling tool:", e); 83 | 84 | return formatCallToolResult({ 85 | error: e instanceof Error ? e.message : String(e), 86 | }); 87 | } 88 | } 89 | 90 | export function clearTools() { 91 | tools.length = 0; 92 | } 93 | -------------------------------------------------------------------------------- /src/tools/clear-context.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, TextContent, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { patchSession } from "../session.js"; 5 | import { clearToolsCache } from "../tools.js"; 6 | import { clearResourcesCache } from "../resources.js"; 7 | 8 | export const name = "clear_context"; 9 | 10 | export const annotations: ToolAnnotations = { 11 | title: "Clear Context", 12 | }; 13 | 14 | export const modes: DynamicToolMode[] = ["consumerOD", "library", "file", "folder", "site"]; 15 | 16 | export const description = `This tool clears the current context of the server. It can be used to reset to the root`; 17 | 18 | export const handler = async function (this: MCPContext): Promise { 19 | 20 | const { session, server } = this.params; 21 | 22 | await patchSession(session.sessionId, { 23 | mode: "not-set", 24 | currentContextRoot: "", 25 | }); 26 | 27 | // trigger update on tools with new mode 28 | await clearToolsCache(server); 29 | 30 | // trigger update on resources with new mode 31 | await clearResourcesCache(server); 32 | 33 | return { 34 | role: "user", 35 | content: [ 36 | { 37 | type: "text", 38 | text: `The current context has been cleared.`, 39 | }, 40 | ], 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/tools/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { AudioContent, BlobResourceContents, ImageContent, TextContent } from "@modelcontextprotocol/sdk/types.js"; 2 | import { ValidCallToolContent, ValidCallToolResult } from "../../types.js"; 3 | 4 | export async function parseResponseToResult(response: Response): Promise { 5 | 6 | if (!response.ok) { 7 | throw Error(`(${response.status}): ${JSON.stringify(await response.text())}`); 8 | } 9 | 10 | let responseData: any; 11 | let mimeType = response.headers.get("Content-Type"); 12 | 13 | try { 14 | 15 | if (/text|json/i.test(mimeType)) { 16 | 17 | const responseText = await response.text(); 18 | // Try to parse as JSON 19 | responseData = responseText ? JSON.parse(responseText) : {}; 20 | 21 | } else { 22 | 23 | const buffer = await response.arrayBuffer(); 24 | responseData = Buffer.from(buffer).toString("base64"); 25 | } 26 | 27 | } catch (e) { 28 | 29 | console.error(e); 30 | 31 | // If not JSON, use the raw text 32 | responseData = { rawResponse: `Error: ${e}` }; 33 | } 34 | 35 | return formatCallToolResult(responseData, mimeType); 36 | } 37 | 38 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types 39 | export function formatCallToolResult(value: any, mimeType: string = "text/json", uriStr?: string): ValidCallToolResult { 40 | 41 | let resultContent: ValidCallToolContent[] = []; 42 | 43 | if (/text|json/i.test(mimeType)) { 44 | 45 | resultContent.push( 46 | { 47 | type: "text", 48 | text: JSON.stringify(value, null, 2), 49 | mimeType, 50 | }); 51 | 52 | } else if (/image\//i.test(mimeType)) { 53 | 54 | resultContent.push( 55 | { 56 | type: "image", 57 | data: value, 58 | mimeType: mimeType, 59 | }); 60 | 61 | } else if (/audio\//i.test(mimeType)) { 62 | 63 | resultContent.push( 64 | { 65 | type: "audio", 66 | data: value, 67 | mimeType, 68 | }); 69 | 70 | } else { 71 | 72 | resultContent.push( 73 | { 74 | type: "resource", 75 | mimeType, 76 | blob: value, 77 | }); 78 | } 79 | 80 | return { 81 | role: "user", 82 | content: resultContent, 83 | }; 84 | } -------------------------------------------------------------------------------- /src/tools/create-file.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, TextContent, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { combine } from "../utils.js"; 5 | import { createDriveItemResourceKey } from "../resources/core/utils.js"; 6 | 7 | export const name = "create_file"; 8 | 9 | export const annotations: ToolAnnotations = { 10 | title: "Create File", 11 | }; 12 | 13 | export const modes: DynamicToolMode[] = ["consumerOD", "library", "folder", "site"]; 14 | 15 | export const description = `This tool allows you to create a new file.`; 16 | 17 | export const inputSchema = { 18 | type: "object", 19 | properties: { 20 | name: { 21 | type: "string", 22 | description: "The name of the new file.", 23 | }, 24 | content: { 25 | type: "string", 26 | description: "The content to place in the file.", 27 | }, 28 | }, 29 | required: ["name", "content"], 30 | }; 31 | 32 | export const handler = async function (this: MCPContext): Promise { 33 | 34 | const { session, request } = this.params; 35 | 36 | let name = request.params.arguments.name; 37 | let content = request.params.arguments.content; 38 | 39 | let path: string; 40 | 41 | switch (session.mode) { 42 | case "site": 43 | path = combine(session.currentContextRoot, `drive/root:/${name}:/content`); 44 | break; 45 | case "consumerOD": 46 | path = combine(session.currentContextRoot, "root/delta"); 47 | break; 48 | case "folder": 49 | path = combine(session.currentContextRoot, `:/${name}:/content`); 50 | break; 51 | case "library": 52 | path = combine(session.currentContextRoot, `/root:/${name}:/content`); 53 | break; 54 | } 55 | 56 | const result: any = await this.fetch(path, { 57 | method: "PUT", 58 | body: content, 59 | }); 60 | 61 | result.file_key = createDriveItemResourceKey(result); 62 | 63 | return { 64 | role: "user", 65 | content: [ 66 | { 67 | type: "text", 68 | text: `The file was successfully created, you can reference it using the resource uri ${result.file_key}. The metadata is also included in this response.`, 69 | }, 70 | { 71 | type: "text", 72 | text: JSON.stringify(result, null, 2), 73 | mimeType: "application/json", 74 | } 75 | ], 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/tools/get-context.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, TextContent, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | 5 | export const name = "get_context"; 6 | 7 | export const annotations: ToolAnnotations = { 8 | title: "Get Context", 9 | readOnlyHint: true, 10 | }; 11 | 12 | export const modes: DynamicToolMode[] = ["consumerOD", "library", "file", "folder", "site"]; 13 | 14 | export const description = `This tool gets the current context of the server. It will also supply additional contextual information. 15 | The setting can be managed using the set_context tool.`; 16 | 17 | export const handler = async function (this: MCPContext): Promise { 18 | 19 | const { session } = this.params; 20 | 21 | return { 22 | role: "user", 23 | content: [ 24 | { 25 | type: "text", 26 | text: `The current contextual mode is ${session.mode} with a base url of ${session.currentContextRoot}`, 27 | }, 28 | ], 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/tools/get-file.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { combine, decodePathFromBase64 } from "../utils.js"; 4 | import { MCPContext } from "../method-context.js"; 5 | import { parseResponseToResult } from "./core/utils.js"; 6 | // import { createDriveItemResourceKey } from "../../resources/core/utils.js"; 7 | 8 | export const name = "get_file"; 9 | 10 | export const annotations: ToolAnnotations = { 11 | title: "Get File Content and Info", 12 | readOnlyHint: true, 13 | }; 14 | 15 | export const description = "Get the content, metadata, or pdf representation of a file. It supports three operations, 'metadata', 'content', or 'pdf'. You can supply one or more operations at a time."; 16 | 17 | export const modes: DynamicToolMode[] = ["file", "folder", "library", "site"]; 18 | 19 | export const inputSchema = { 20 | type: "object", 21 | properties: { 22 | file_key: { 23 | type: "string", 24 | description: "The resource identifier using file:// protocol. You can provide the entire resource uri, or just the key part represented by the uri host value.", 25 | }, 26 | operations: { 27 | type: "array", 28 | items: { type: "string" }, 29 | description: `What information we want about the file, any of 'metadata' (default), 'content', or 'pdf'. You can supply one or more operations.`, 30 | }, 31 | }, 32 | required: ["file_key"], 33 | }; 34 | 35 | export const handler = async function (this: MCPContext): Promise { 36 | 37 | const { request } = this.params; 38 | 39 | const operations: string[] = request.params.arguments.operations || ["metadata"]; 40 | 41 | // let path: string; 42 | let fileKey = request.params.arguments.file_key 43 | 44 | fileKey = fileKey.replace(/^file:\/\//i, ""); 45 | 46 | const path = decodePathFromBase64(fileKey); 47 | 48 | const responses: ValidCallToolResult[] = []; 49 | 50 | let driveItemPath = path; 51 | 52 | for (let i = 0; i < operations.length; i++) { 53 | 54 | if (/content/i.test(operations[i])) { 55 | 56 | driveItemPath = combine(driveItemPath, "contentStream"); 57 | 58 | } else if (/pdf/i.test(operations[i])) { 59 | 60 | driveItemPath = combine(driveItemPath, "content?format=pdf"); 61 | } 62 | 63 | responses.push(await this.fetch(driveItemPath, {}, true).then(parseResponseToResult)); 64 | } 65 | 66 | return { 67 | role: "user", 68 | content: responses.reduce((pv, v) => { 69 | pv.push(...v.content); 70 | return pv; 71 | }, []), 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/tools/hidden/get-drive.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../../types.js"; 3 | import { combine } from "../../utils.js"; 4 | import { MCPContext } from "../../method-context.js"; 5 | import { parseResponseToResult } from "../core/utils.js"; 6 | 7 | export const name = "files_get_drive"; 8 | 9 | export const modes: DynamicToolMode[] = ["library"]; 10 | 11 | export const description = "Get the details about a single Drive by id"; 12 | 13 | export const inputSchema = { 14 | type: "object", 15 | properties: { 16 | drive_id: { 17 | type: "string", 18 | description: "The ID of the drive whose details we seek", 19 | }, 20 | }, 21 | required: ["drive_id"], 22 | }; 23 | 24 | export const handler = async function (this: MCPContext): Promise { 25 | 26 | const { request } = this.params; 27 | 28 | return this.fetch(combine("drives", request.params.arguments.drive_id), {}, true).then(parseResponseToResult); 29 | }; 30 | -------------------------------------------------------------------------------- /src/tools/hidden/readme.txt: -------------------------------------------------------------------------------- 1 | This is a folder to drop testing/old/out of date whatever tools. This way they stay in the project but are not exposed by the server. -------------------------------------------------------------------------------- /src/tools/hidden/test-long-running.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../../types.js"; 3 | import { MCPContext } from "../../method-context.js"; 4 | 5 | export const name = "test_long_running"; 6 | 7 | export const modes: DynamicToolMode[] = ["hidden"]; 8 | 9 | export const description = "A test tool for checking a long running operation"; 10 | 11 | export const handler = async function (this: MCPContext): Promise { 12 | 13 | const { request, server } = this.params; 14 | 15 | const progressToken = request.params._meta?.progressToken; 16 | const steps = 5; 17 | 18 | for (let i = 1; i < steps + 1; i++) { 19 | 20 | await new Promise((resolve) => 21 | setTimeout(resolve, 5000) 22 | ); 23 | 24 | if (progressToken !== undefined) { 25 | 26 | await server.notification({ 27 | method: "notifications/progress", 28 | params: { 29 | progress: i, 30 | total: steps, 31 | progressToken, 32 | }, 33 | }); 34 | } 35 | } 36 | 37 | return { 38 | role: "user", 39 | content: [{ 40 | type: "text", 41 | text: `Long running operation completed. Steps: ${steps}.`, 42 | }], 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/tools/hidden/test-paging.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, GenericPagedResponse, ValidCallToolResult } from "../../types.js"; 3 | import { MCPContext } from "../../method-context.js"; 4 | import { getNextCursor } from "../../utils.js"; 5 | 6 | // Paging doesn't seem supported for tool results as of May 14 7 | export const name = "test_paging"; 8 | 9 | export const modes: DynamicToolMode[] = ["hidden"]; 10 | 11 | export const description = "A test tool for trying out paging"; 12 | 13 | export const inputSchema = { 14 | type: "object", 15 | properties: { 16 | page_size: { 17 | type: "number", 18 | description: "The page size of returned results.", 19 | }, 20 | cursor: { 21 | type: "string", 22 | description: "The nextCursor from previous requests.", 23 | }, 24 | }, 25 | required: [], 26 | }; 27 | 28 | export const handler = async function (this: MCPContext): Promise { 29 | 30 | const { request } = this.params; 31 | 32 | let urlBase = "/sites/delta"; 33 | 34 | const pageSize = request.params?.arguments?.page_size || 25; 35 | 36 | if (request.params?.arguments?.cursor) { 37 | // continue 38 | urlBase += `?token=${request.params.arguments.cursor}&$top=${pageSize}`; 39 | } else { 40 | urlBase += `?$top=${pageSize}`; 41 | } 42 | 43 | const result = await this.fetch(urlBase); 44 | 45 | const results = result.value; 46 | const nextCursor = getNextCursor(result); 47 | 48 | return { 49 | role: "user", 50 | content: [{ 51 | type: "text", 52 | mimeType: "application/json", 53 | text: JSON.stringify(results, null, 2), 54 | }], 55 | nextCursor, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/tools/list-files.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { combine, withProgress } from "../utils.js"; 5 | import { formatCallToolResult } from "./core/utils.js"; 6 | import { createDriveItemResourceKey } from "../resources/core/utils.js"; 7 | 8 | export const name = "list_files"; 9 | 10 | export const annotations: ToolAnnotations = { 11 | title: "List Files", 12 | readOnlyHint: true, 13 | }; 14 | 15 | export const modes: DynamicToolMode[] = ["site", "consumerOD", "folder", "library"]; 16 | 17 | export const description = `Lists the files in the current context. If no context is available it will use the tenant's root site's default drive. If the context is a site, it will list the files 18 | in that site's default document library. If the context is a drive or a folder, it will list all the child files and folders.`; 19 | 20 | export const handler = async function (this: MCPContext): Promise { 21 | 22 | const { session } = this.params; 23 | 24 | let path: string; 25 | 26 | switch (session.mode) { 27 | case "site": 28 | path = combine(session.currentContextRoot, "drive/root/delta"); 29 | break; 30 | case "consumerOD": 31 | path = combine(session.currentContextRoot, "root/delta"); 32 | break; 33 | case "folder": 34 | path = combine(session.currentContextRoot, "delta"); 35 | break; 36 | case "library": 37 | path = combine(session.currentContextRoot, "root/delta"); 38 | break; 39 | } 40 | 41 | return withProgress.call(this, this.fetchAndAggregate(path, {}, fileInfoAugmentation).then(result => formatCallToolResult(result, "application/json"))); 42 | }; 43 | 44 | function fileInfoAugmentation(vals: any[]) { 45 | return vals.map(v => { 46 | v.file_key = createDriveItemResourceKey(v); 47 | return v; 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/tools/list-libraries.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { combine, withProgress } from "../utils.js"; 5 | import { formatCallToolResult } from "./core/utils.js"; 6 | 7 | export const name = "list_libraries"; 8 | 9 | export const annotations: ToolAnnotations = { 10 | title: "List Libraries", 11 | readOnlyHint: true, 12 | }; 13 | 14 | export const modes: DynamicToolMode[] = ["not-set", "site"]; 15 | 16 | export const description = "Lists the libraris in the current context. If no context is set, the root site collection's libraries will be listed. Libraries contain files which can be accessed withing the library."; 17 | 18 | export const handler = async function (this: MCPContext): Promise { 19 | 20 | const { session } = this.params; 21 | 22 | let path: string; 23 | 24 | switch (session.mode) { 25 | case "site": 26 | path = combine(session.currentContextRoot, "drives"); 27 | break; 28 | default: 29 | path = "drives"; 30 | } 31 | 32 | return withProgress.call(this, this.fetchAndAggregate(path).then(result => formatCallToolResult(result, "application/json"))); 33 | }; 34 | -------------------------------------------------------------------------------- /src/tools/list-listitems.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { combine, withProgress } from "../utils.js"; 5 | import { formatCallToolResult } from "./core/utils.js"; 6 | 7 | export const name = "list_listitems"; 8 | 9 | export const annotations: ToolAnnotations = { 10 | title: "List ListItems", 11 | readOnlyHint: true, 12 | }; 13 | 14 | //TODO:: test consumer OD lists 15 | export const modes: DynamicToolMode[] = ["list", "folder", "library"]; 16 | 17 | export const description = `Lists the items in the current context. This works for lists and folders within lists. It also works for libraries to list the underlying SharePoint metadata`; 18 | 19 | export const handler = async function (this: MCPContext): Promise { 20 | 21 | const { session } = this.params; 22 | 23 | let path: string; 24 | 25 | switch (session.mode) { 26 | 27 | case "folder": 28 | //TODO:: test this 29 | path = combine(session.currentContextRoot, "delta"); 30 | break; 31 | 32 | case "library": 33 | case "list": 34 | path = combine(session.currentContextRoot, "items/delta"); 35 | break; 36 | } 37 | 38 | return withProgress.call(this, this.fetchAndAggregate(path).then(result => formatCallToolResult(result, "application/json"))); 39 | }; 40 | -------------------------------------------------------------------------------- /src/tools/list-lists.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { combine, withProgress } from "../utils.js"; 5 | import { formatCallToolResult } from "./core/utils.js"; 6 | 7 | export const name = "list_lists"; 8 | 9 | export const annotations: ToolAnnotations = { 10 | title: "List Lists", 11 | readOnlyHint: true, 12 | }; 13 | 14 | export const modes: DynamicToolMode[] = ["site"]; 15 | 16 | export const description = "Lists the SharePoint Lists in the current site."; 17 | 18 | export const handler = async function (this: MCPContext): Promise { 19 | 20 | const { session } = this.params; 21 | 22 | let path: string; 23 | 24 | switch (session.mode) { 25 | case "site": 26 | path = combine(session.currentContextRoot, "lists"); 27 | break; 28 | default: 29 | path = "lists"; 30 | } 31 | 32 | return withProgress.call(this, this.fetchAndAggregate(path).then(result => formatCallToolResult(result, "application/json"))); 33 | }; 34 | -------------------------------------------------------------------------------- /src/tools/list-sites.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { withProgress } from "../utils.js"; 5 | import { formatCallToolResult } from "./core/utils.js"; 6 | 7 | export const name = "list_sites"; 8 | 9 | export const annotations: ToolAnnotations = { 10 | title: "List Sites", 11 | readOnlyHint: true, 12 | }; 13 | 14 | export const modes: DynamicToolMode[] = ["not-set"]; 15 | 16 | export const description = "Lists the sites in a tenant"; 17 | 18 | export const handler = async function (this: MCPContext): Promise { 19 | 20 | // we need to write the code to loop all the sites and then make that work. 21 | 22 | 23 | // this could take along time, so we should alert on progress in case we go over a timeout 24 | return withProgress.call(this, this.fetchAndAggregate("sites?$filter=siteCollection/root ne null&$select=siteCollection,webUrl", {}).then(result => formatCallToolResult(result, "application/json"))); 25 | }; 26 | 27 | // function siteInfoAugmentation(vals: any[]) { 28 | // return vals.map(v => v.file_key = createSiteResourceKey(v)); 29 | // } 30 | 31 | -------------------------------------------------------------------------------- /src/tools/set-context.ts: -------------------------------------------------------------------------------- 1 | import { CallToolRequest, TextContent, TextResourceContents, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; 2 | import { COMMON, DynamicToolMode, ValidCallToolResult } from "../types.js"; 3 | import { MCPContext } from "../method-context.js"; 4 | import { patchSession } from "../session.js"; 5 | import { clearToolsCache } from "../tools.js"; 6 | import { clearResourcesCache } from "../resources.js"; 7 | import { combine, decodePathFromBase64, encodePathToBase64 } from "../utils.js"; 8 | 9 | export const name = "set_context"; 10 | 11 | export const annotations: ToolAnnotations = { 12 | title: "Set Context", 13 | }; 14 | 15 | export const modes: DynamicToolMode[] = [COMMON]; 16 | 17 | export const description = `This tool allows you to change the context of this mcp server by providing a URL (or a valid resource URI obtained from this server) to a resource to use as the contextual entry point. 18 | Almost any valid SharePoint or OneDrive url will work, and the tool will return an error if the context cannot be identified. 19 | The context can be a site, folder, or file. Changing the context will update the list of available tools and resources. Most 20 | entities include a webUrl in the response which you can use with this tool.`; 21 | 22 | export const inputSchema = { 23 | type: "object", 24 | properties: { 25 | context_url: { 26 | type: "string", 27 | description: "The url to the new contextual root.", 28 | }, 29 | }, 30 | required: ["context_url"], 31 | }; 32 | 33 | interface ResolvedEntityInfo { 34 | mode: DynamicToolMode; 35 | contextBase: string; 36 | metadata: any; 37 | } 38 | 39 | export const handler = async function (this: MCPContext): Promise { 40 | 41 | const { request, session, server } = this.params; 42 | 43 | const contextUrl = request.params.arguments.context_url; 44 | const shareKey = "u!" + Buffer.from(contextUrl, "utf8").toString("base64").replace(/=$/i, "").replace("/", "_").replace("+", "-"); 45 | 46 | // these are roughly in order of our estimation on usage. 47 | const resolvers: (() => Promise)[] = [ 48 | async () => { 49 | 50 | const uri = new URL(contextUrl); 51 | const cleanProtocol: DynamicToolMode = uri.protocol.replace(/:$/, ""); 52 | 53 | if ((["file", "folder", "site", "list", "library", "listitem"]).indexOf(cleanProtocol) > -1) { 54 | 55 | // this is not a share, this is one of our resource URIs 56 | 57 | const decodedPath = decodePathFromBase64(contextUrl.replace(/^.*?:\/\//, "")); 58 | 59 | const metadata = await this.fetch(decodedPath); 60 | 61 | return { 62 | mode: cleanProtocol, 63 | contextBase: decodedPath, 64 | metadata, 65 | }; 66 | } 67 | 68 | throw Error(`Failed to parse resource id path ${contextUrl}.`); 69 | }, 70 | async () => { 71 | 72 | // file/folder 73 | const result = await this.fetch<{ driveItem: { id: string, root?: any; folder?: any; parentReference: { driveId } } }>(`/shares/${shareKey}?$expand=driveItem`); 74 | let mode: DynamicToolMode; 75 | let contextBase: string; 76 | 77 | if (result.driveItem?.root) { 78 | mode = "library"; 79 | contextBase = `/drives/${result.driveItem.parentReference.driveId}`; 80 | } else { 81 | mode = result.driveItem?.folder ? "folder" : "file"; 82 | contextBase = `/drives/${result.driveItem.parentReference.driveId}/items/${result.driveItem.id}`; 83 | } 84 | 85 | return { 86 | mode, 87 | contextBase, 88 | metadata: result.driveItem, 89 | }; 90 | }, 91 | async () => { 92 | // list 93 | const result = await this.fetch<{ list: { id: string, parentReference: { siteId: string } } }>(`/shares/${shareKey}?$expand=list`); 94 | return { 95 | mode: "list", 96 | contextBase: `/sites/${result.list.parentReference.siteId}/lists/${result.list.id}`, 97 | metadata: result.list, 98 | }; 99 | }, 100 | async () => { 101 | // site 102 | const result = await this.fetch<{ site: { id: string } }>(`/shares/${shareKey}?$expand=site`); 103 | return { 104 | mode: "site", 105 | contextBase: `/sites/${result.site.id}`, 106 | metadata: result.site, 107 | }; 108 | }, 109 | async () => { 110 | // tenant root, site path, or site id 111 | let parsedURI = URL.parse(contextUrl); 112 | const result = await this.fetch<{ id: string }>(`/sites/${combine(parsedURI.host, parsedURI.pathname)}`); 113 | return { 114 | mode: "site", 115 | contextBase: `/sites/${result.id}`, 116 | metadata: result, 117 | }; 118 | } 119 | ] 120 | 121 | const resolverErrors = []; 122 | 123 | for (let i = 0; i < resolvers.length; i++) { 124 | 125 | try { 126 | 127 | const { mode, contextBase, metadata } = await resolvers[i](); 128 | await patchSession(session.sessionId, { 129 | mode, 130 | currentContextRoot: contextBase, 131 | }); 132 | 133 | // trigger update on tools with new mode 134 | await clearToolsCache(server); 135 | 136 | // trigger update on resources with new mode 137 | await clearResourcesCache(server); 138 | 139 | const uriStr = `${mode}://${encodePathToBase64(contextBase)}`; 140 | 141 | return { 142 | role: "user", 143 | content: [ 144 | { 145 | type: "text", 146 | text: `We located the requested context using the path '${contextUrl}', it appears to be a ${mode}. We've also include some initial metadata.`, 147 | }, 148 | { 149 | uri: uriStr, 150 | type: "text", 151 | mimeType: "application/json", 152 | text: JSON.stringify(metadata, null, 2), 153 | }, 154 | { 155 | type: "text", 156 | text: `The uri '${uriStr}' can be used with resource templates where the uri host represents the key required for the available protocols. Keys only work with the protocol they are delivered with, but work for any 157 | resource template associated with that protocol. This key is not the same as the id value returned in entity metadata and is unique to this server.`, 158 | }, 159 | ], 160 | }; 161 | 162 | } catch (e) { 163 | resolverErrors.push(e.message); 164 | } 165 | } 166 | 167 | throw Error(resolverErrors.join("; ")); 168 | }; 169 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioContent, 3 | BlobResourceContents, 4 | CallToolRequest, 5 | CallToolResult, 6 | ImageContent, 7 | Notification, 8 | ReadResourceRequest, 9 | ReadResourceResult, 10 | Request, 11 | RequestId, 12 | RequestMeta, 13 | Resource, 14 | ResourceTemplate, 15 | TextContent, 16 | Tool, 17 | } from "@modelcontextprotocol/sdk/types.js"; 18 | import { MCPContext } from "./method-context.js"; 19 | import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; 20 | import { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; 21 | import { MCPSession } from "./session.js"; 22 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 23 | 24 | export const COMMON = "common"; 25 | 26 | export type DynamicToolMode = "site" | "library" | "folder" | "list" | "file" | "listitem" | "consumerOD" | typeof COMMON | "not-set"| "hidden"; 27 | 28 | export interface DynamicTool extends Tool { 29 | annotations?: { 30 | [key: string]: unknown; 31 | } 32 | modes: DynamicToolMode[]; 33 | handler: (this: MCPContext) => Promise; 34 | } 35 | 36 | export interface DynamicResource { 37 | publish(this: MCPContext): Promise; 38 | handler(this: MCPContext): Promise; 39 | } 40 | 41 | export interface DynamicResourceTemplate { 42 | publish(this: MCPContext): Promise; 43 | } 44 | 45 | export type ValidCallToolContent = TextContent | ImageContent | AudioContent | BlobResourceContents; 46 | 47 | export type ValidCallToolResult = CallToolResult & { 48 | role: "user" | "assistant", 49 | content: ValidCallToolContent[], 50 | } 51 | 52 | export type ResourceReadHandlerResult = Resource[] | { 53 | resources: Resource[]; 54 | nextCursor: string; 55 | }; 56 | 57 | export type ResourceReadHandlerTest = (uri: URL, context: MCPContext) => boolean; 58 | 59 | export type ResourceReadHandler = (this: MCPContext, uri: URL) => Promise; 60 | 61 | export type ResourceReadHandlerMap = Map, ResourceReadHandler>; 62 | 63 | export interface DynamicToolExtra { 64 | /** 65 | * An abort signal used to communicate if the request was cancelled from the sender's side. 66 | */ 67 | signal: AbortSignal; 68 | /** 69 | * Information about a validated access token, provided to request handlers. 70 | */ 71 | authInfo?: AuthInfo; 72 | /** 73 | * The session ID from the transport, if available. 74 | */ 75 | sessionId?: string; 76 | /** 77 | * Metadata from the original request. 78 | */ 79 | _meta?: RequestMeta; 80 | /** 81 | * The JSON-RPC ID of the request being handled. 82 | * This can be useful for tracking or logging purposes. 83 | */ 84 | requestId: RequestId; 85 | } 86 | 87 | export interface HandlerParams { 88 | server: Server; 89 | request: RequestT; 90 | extra: RequestHandlerExtra; 91 | session: MCPSession; 92 | token?: string; 93 | } 94 | 95 | export interface GenericPagedResponse { 96 | value: { 97 | id: string, 98 | [key: string]: any 99 | }[]; 100 | "@odata.nextLink"?: string; 101 | "@odata.deltaLink"?: string; 102 | } 103 | 104 | 105 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { MCPContext } from "./method-context.js"; 2 | import { GenericPagedResponse } from "./types.js"; 3 | 4 | /** 5 | * Combines an arbitrary set of paths ensuring and normalizes the slashes 6 | * 7 | * @param paths 0 to n path parts to combine 8 | */ 9 | export function combine(...paths: (string | null | undefined)[]): string { 10 | 11 | return paths 12 | .filter(path => !stringIsNullOrEmpty(path)) 13 | .map(path => path.replace(/^[\\|/]/, "").replace(/[\\|/]$/, "")) 14 | .join("/") 15 | .replace(/\\/g, "/"); 16 | } 17 | 18 | /** 19 | * Determines if a string is null or empty or undefined 20 | * 21 | * @param s The string to test 22 | */ 23 | export function stringIsNullOrEmpty(s: string | undefined | null): s is undefined | null | "" { 24 | return typeof s === "undefined" || s === null || s.length < 1; 25 | } 26 | 27 | export function encodePathToBase64(path: string): string { 28 | 29 | if (stringIsNullOrEmpty(path)) { 30 | return path; 31 | } 32 | 33 | return Buffer.from(path).toString("base64").replace(/=$/i, "").replace("/", "_").replace("+", "-"); 34 | } 35 | 36 | export function decodePathFromBase64(base64: string): string { 37 | 38 | return Buffer.from(base64.replace("-", "+").replace("_", "/").concat("="), "base64").toString("utf8"); 39 | } 40 | 41 | export interface GetNextCursorOptions { 42 | encode: boolean; 43 | includeDelta: boolean; 44 | } 45 | 46 | export function getNextCursor(result: GenericPagedResponse, options?: Partial): [string | undefined, boolean] { 47 | 48 | let nextCursor; 49 | let isDelta = false; 50 | 51 | const { encode, includeDelta } = { 52 | encode: true, 53 | includeDelta: false, 54 | ...options, 55 | }; 56 | 57 | if (result["@odata.nextLink"]) { 58 | 59 | // we first page through this result set 60 | nextCursor = result["@odata.nextLink"]; 61 | 62 | } else if (includeDelta && result["@odata.deltaLink"]) { 63 | 64 | isDelta = true; 65 | // otherwise we send the token to get the next delta response 66 | nextCursor = result["@odata.deltaLink"]; 67 | } 68 | 69 | if (encode) { 70 | nextCursor = encodePathToBase64(nextCursor); 71 | } 72 | 73 | return [nextCursor, isDelta]; 74 | } 75 | 76 | export interface WithProgressOptions { 77 | timeout: number; 78 | } 79 | 80 | export async function withProgress(this: MCPContext, promise: Promise, options?: WithProgressOptions) { 81 | 82 | const { server, request } = this.params; 83 | const progressToken = request.params._meta?.progressToken; 84 | let steps = 0; 85 | let clear; 86 | 87 | const { timeout } = { 88 | timeout: 5000, 89 | ...options, 90 | }; 91 | 92 | const progress = () => { 93 | 94 | clear = setTimeout(async () => { 95 | 96 | await server.notification({ 97 | method: "notifications/progress", 98 | params: { 99 | progress: ++steps, 100 | total: steps + 1, 101 | progressToken, 102 | }, 103 | }); 104 | 105 | progress(); 106 | 107 | }, timeout); 108 | } 109 | 110 | progress(); 111 | 112 | const result = await promise; 113 | 114 | clearTimeout(clear); 115 | 116 | return result; 117 | } 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "esnext", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "removeComments": false, 8 | "lib": [ 9 | "ES2015", 10 | "dom", 11 | "ES2017.Object", 12 | "ESNext.Iterator" 13 | ], 14 | "baseUrl": ".", 15 | "rootDir": "./src", 16 | "outDir": "./build", 17 | "allowUnreachableCode": false, 18 | "allowSyntheticDefaultImports": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "moduleResolution": "node", 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": false, 23 | "pretty": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "sourceMap": true, 26 | "skipDefaultLibCheck": true, 27 | "skipLibCheck": true, 28 | "strictNullChecks": false, 29 | "preserveConstEnums": false, 30 | "importHelpers": true 31 | }, 32 | "include": [ 33 | "./src/**/*.ts", 34 | ] 35 | } --------------------------------------------------------------------------------