Initial commit
This commit is contained in:
11
.claude-plugin/plugin.json
Normal file
11
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "mxcp-plugin",
|
||||
"description": "A Claude plugin for MXCP",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "RAW Labs"
|
||||
},
|
||||
"skills": [
|
||||
"./skills"
|
||||
]
|
||||
}
|
||||
617
plugin.lock.json
Normal file
617
plugin.lock.json
Normal file
@@ -0,0 +1,617 @@
|
||||
{
|
||||
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||
"pluginId": "gh:raw-labs/claude-code-marketplace:.claude-plugin/plugins/mxcp-plugin",
|
||||
"normalized": {
|
||||
"repo": null,
|
||||
"ref": "refs/tags/v20251128.0",
|
||||
"commit": "4e714d6c5864c34727a2dfacb3ba9818c85bb10d",
|
||||
"treeHash": "e065970c2bf43e0c06482499a934af49e7573360266bb2341993ae9154ffbbc1",
|
||||
"generatedAt": "2025-11-28T10:27:48.113763Z",
|
||||
"toolVersion": "publish_plugins.py@0.2.0"
|
||||
},
|
||||
"origin": {
|
||||
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||
"branch": "master",
|
||||
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "mxcp-plugin",
|
||||
"description": "A Claude plugin for MXCP",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"content": {
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "a009df5cc5e005d8320dbb28240eff156d2b7dc6fb2e2f4ae0687a96fc18a663"
|
||||
},
|
||||
{
|
||||
"path": ".claude-plugin/plugin.json",
|
||||
"sha256": "9464fcb9cf8ea4e7fd4652bc8f80b491785ed6d8f94f1e1b3c98ac9d91044a87"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/llms.txt",
|
||||
"sha256": "567959f05e313b59f4700e50ad1a5ac16eb56850da90591dae0de60230ab7619"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/README.md",
|
||||
"sha256": "056135b4d7f9b96f04b089bf6869f444ed7c69dae9881f37c483db10875e77d2"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/SKILL.md",
|
||||
"sha256": "16b425bf47ff1e8a498674fa5e6fcdd35dcd13b9c946e7290c93b61d2c2ffb66"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/synthetic-data-patterns.md",
|
||||
"sha256": "3be31ac12c6866994ff564b7fb85241ba39b491c99e6eb663c622f791620666d"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/duckdb-essentials.md",
|
||||
"sha256": "4668426477b7ab3acfc0e79e90991168c5fc22ee1d09a6cc2ddd337780922e31"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/type-system.md",
|
||||
"sha256": "ad55678ed61e26bd116c3f67b37c73e7b62cadfd4d2d1682409eddfa93f10628"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/agent-centric-design.md",
|
||||
"sha256": "a9bff3f01b1be49fd9f00e410ae05122878ec8b20a1633b1c50598f646401885"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/mxcp-evaluation-guide.md",
|
||||
"sha256": "227ce5d3aeff3dfab46b60b682118d2b4aae9be4b992db03a4c35c1c7e658ed3"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/dbt-core-guide.md",
|
||||
"sha256": "299d2aa1adb6e54acfead9d4f3986f014f3d1fed580602dc8adfa10d3a59ccb0"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/project-selection-guide.md",
|
||||
"sha256": "4526f3a2e3ee45c877cdb14125223764be3111c24c585e92f0994904b2c62a3f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/policies.md",
|
||||
"sha256": "b0b73bee534f9eeb9f620880a472a6700461f34e9b45ca6bdbfd168864c56156"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/tool-templates.md",
|
||||
"sha256": "a5eca354ae91ae8009493a855332d8320728bfd655280b1159c1327cd7383c47"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/endpoint-patterns.md",
|
||||
"sha256": "7fcae5c115859223b8e4805a54806b440c949ed36d355b11ddceef052411a031"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/excel-integration.md",
|
||||
"sha256": "82bf62a47d44273f09f47e43092e94b68be8cb2e1172b069022149e4418545b6"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/minimal-working-examples.md",
|
||||
"sha256": "053348b2f06b7465ae7f4b2ccb63433e2cc272caa18ab1f63377303bccf2537b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/python-development-workflow.md",
|
||||
"sha256": "9a74637fd5fac5a814c0cd11b61f52f86f23cc91a6c645f0b7af48b631058cb6"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/comprehensive-testing-guide.md",
|
||||
"sha256": "a7381352b2a5d3da38136a9797d3eaf1b6536f57436183eb928850357ac9cee9"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/python-api.md",
|
||||
"sha256": "b34526e796484cf20fc0b5b5487ff9657e185359b4b59f6698446063c5f7e3aa"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/llm-friendly-documentation.md",
|
||||
"sha256": "a03c0a2b931f9eea4345f2a17e007a74a7764b73ebcf452701da5d9cb92e715e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/debugging-guide.md",
|
||||
"sha256": "8af3585610e78afb6638790ba1f9dd814d3d0a2d2e3bc17020ef3b78d610ca56"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/dbt-patterns.md",
|
||||
"sha256": "7d97306574581735c66d3dcdb7b6fa67330b616f165faaf047aaab2ec29db1b0"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/cli-reference.md",
|
||||
"sha256": "26ee767dca68b501b464eef3a4ae88dca74d98f580cbd232ce32ea9d98300f1b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/claude-desktop.md",
|
||||
"sha256": "45a182ba75239a991a4b6a4fdca8e56a581500772fb2d275cccccbdf0981959f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/database-connections.md",
|
||||
"sha256": "77337236b0503ee1a4ccc3bdcb1cf4578beea86d3d98d9abe901baf476aacc93"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/testing-guide.md",
|
||||
"sha256": "513913db746593825d8a33631190a7de0f13ab164c2aec1a3d8649762c50fefb"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/build-and-validate-workflow.md",
|
||||
"sha256": "39929c16fa1918da5602233095211e41a3430ad0958c85c297492a4007fe17cc"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/references/error-handling-guide.md",
|
||||
"sha256": "21dd48c7fbfcaf8238b9a97bdf04211af5c21ff8a714eb6583aab44bfbf42985"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/scripts/validate_yaml.py",
|
||||
"sha256": "3a907ef722690a3d763b9b7edd223a275eb055b889b86021da32938ca955bb69"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/mxcp-config-schema-1.json",
|
||||
"sha256": "6e1eefed8ae59f3875a614b2f09c52d968cbe1bd0409e33fce2079c980ed5972"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/mxcp-site-schema-1.json",
|
||||
"sha256": "7f0e0ab37d48c733a4daf6bc28ddf76254be35864a0dcfc472caa781dc78720f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/drift-snapshot-schema-1.json",
|
||||
"sha256": "5b10c612221cf9aa01e9c621883510d4e89140d4355ae08e272044a724c12ff8"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/common-types-schema-1.json",
|
||||
"sha256": "509f4690bbe850c1acc01923db1f5d0f56cc8cab1081e9dd0dd57ddebc16432c"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/eval-schema-1.json",
|
||||
"sha256": "f471f062733b30b89d223a1afe2a1d901053dd9a2a3e7a2a65be9b12040db69b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/tool-schema-1.json",
|
||||
"sha256": "7a5bac4698630e3553152097ddc8e43f163f44b460e000c612b77fc96fe1739b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/prompt-schema-1.json",
|
||||
"sha256": "1aa8bcb83d7c444aa5f9ab7b691f185f7c9aa0a3558531209fd9e6ba75eafff8"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/resource-schema-1.json",
|
||||
"sha256": "850d00aa834dd272b0559d75db5a349262323793a4e6486a69990d6d32dd9865"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/schemas/drift-report-schema-1.json",
|
||||
"sha256": "4d6bc5343e3ee5d87f13265b4dea46ea0effe43c6a558b57437ca1c0ab1804e7"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/plugin/README.md",
|
||||
"sha256": "9e6fa239223787a0e74b972bdcef3405895ee5fd13422e94819b0b91a121248b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/plugin/config.yml",
|
||||
"sha256": "4433b883f0ecb1bac345910e5aaa4efaf19ec9668f7519f1beeb9006a402eb4e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/plugin/mxcp-site.yml",
|
||||
"sha256": "863657c532fd430e68e0e4c15523a33ced193306470f5896e93457d7ed1458ed"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/plugin/tools/decipher.yml",
|
||||
"sha256": "ee2420c5a8acdb91c9d1b69ff20a9a896cbe347111189b0ca198e0c16acf4d8e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/plugin/plugins/README.md",
|
||||
"sha256": "a19de0cd5c0edfa98028d2e64b880278fc44cdd615f451b34cd359ede44ee92e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/plugin/plugins/my_plugin/__init__.py",
|
||||
"sha256": "4704bd6e097614851f13955d056479789f2126b6fb729cf148a3fcbc514c6190"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/README.md",
|
||||
"sha256": "6ff4ef172b2d86a914d98347ad90dbb80403f4225b5f020cc6265658c530d181"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/mxcp-site.yml",
|
||||
"sha256": "8d3ad2691ce6fbfbc80dfbaa1c5a5406403b5ec547d2dab597a02159d068e0e5"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/tools/aggregate_by_category.yml",
|
||||
"sha256": "4fcf90e1f252adb709fc18db31e60156bc60757c4ca3d67100ec4e5f6c5de4f0"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/tools/analyze_numbers.yml",
|
||||
"sha256": "238183dfc8c3456c68db2dec09a01bbf17732c8022515751903feba7c6210423"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/tools/process_time_series.yml",
|
||||
"sha256": "6bbfff9d53df54a8ba1ac30a4cfdb05804b48af9569e73f79750a8b8ce005ec2"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/tools/create_sample_data.yml",
|
||||
"sha256": "58a7c46a1e84756f982603b504e5bf8a719a21364e5376cd95df6086857f131b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/python/data_analysis.py",
|
||||
"sha256": "ecef2a18ab2e8b0cbd44ac4743370bce6d2b5caf4ee0c14e2e3bb5e6804af3b7"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/python-demo/python/primitive_arrays.py",
|
||||
"sha256": "48cf51fee0002a53530e117852567bf0fedc192b4d69b4313582b9b51f1a6e85"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/README.md",
|
||||
"sha256": "e32069faec8556945e8a4c03b864e3d26efa4e0f1861926a30082bb93b682855"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/config.yml",
|
||||
"sha256": "2c6923c365a7ec99f7562f18454f72d43793e7012195b2aefb2bc0499550e5a3"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/mxcp-site.yml",
|
||||
"sha256": "0165bc3fb3f3dbde21fadfd89c00a3c5d8ca37e959d4578694cb1d8f86a083e7"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/search_events.yml",
|
||||
"sha256": "6eebeb69717951c7c6544eb72ed6e799aabe0ddd8681d28f45f4f28fa82f6b9f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/get_event.yml",
|
||||
"sha256": "ba61b7d35bb42ebf8dafd2d5b9560f05f8519fa2b18bda8fe159976c2dbc2b3f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/get_freebusy.yml",
|
||||
"sha256": "88297a6edddd48b6c30a56ef7ec3b33f5ff0e8e1b021584a83bb2833da0e703d"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/get_calendar.yml",
|
||||
"sha256": "e762d81fb32d5bc925edb92ba0e66c1e4826218409e8e30f5bf1eec994257fa4"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/list_events.yml",
|
||||
"sha256": "05665c19097cae1be17fe080b54a360baccd40e6bacbabfd768a094c82a5541e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/list_calendars.yml",
|
||||
"sha256": "02342c3e6547c1fc6d536f13ae46560400b78c44d01c84c4c16999973cc98258"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/tools/whoami.yml",
|
||||
"sha256": "b80be91b28834c644144c9de879a2dd99c6130a58214d83bb1de8d9040eadaa2"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/google-calendar/python/google_calendar_client.py",
|
||||
"sha256": "6b769d9330bda66fc8d8345c4514080bdadc40b8002e8e309efd5391febc49c1"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/README.md",
|
||||
"sha256": "859f2a68e08327be3203be0d2724d15ddacb65228703e630981104b6d22d459d"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/config.yml",
|
||||
"sha256": "b0ca8db576be659ce5a9a945e405a4040c7467de7cd03d636ccd88a65433f12d"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/mxcp-site.yml",
|
||||
"sha256": "4509f3eed2d11a8730e95ed10867800fd8d04906d4ebc1a3f4d60d69a00424a6"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/jql_query.yml",
|
||||
"sha256": "f823682586b1236e7730bbef09c457a3c0bdcbaf1ff416889e1fb6614d4d737b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/get_issue.yml",
|
||||
"sha256": "4477022fbda39c60db9fa8983e86e5aa925fd0ed9574c230a7c9122390b5746c"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/get_user.yml",
|
||||
"sha256": "c5144bc7acd0a85b988c561db0d8372df53c907cfe66b5baf5ed8fae639accd2"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/get_project_role_users.yml",
|
||||
"sha256": "d3585503bfb4c653280a75d622a7f8d51faeef1f90e35e465f42e2a385df00b1"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/get_project_roles.yml",
|
||||
"sha256": "72b7390f9564b60ff1d3eb4859dee9e693269fb6695729b6f9aa21a60bf219b5"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/list_projects.yml",
|
||||
"sha256": "e3c1d60b14337edd945f4a125ca8e3300f4190d0a3e92518dccbcce6f7a2c52c"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/get_project.yml",
|
||||
"sha256": "90f9006b0bc64206032b949f79507e7a0312aa3cf08ab26b4294e9758eedc318"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/tools/search_user.yml",
|
||||
"sha256": "4511bea30fda3dea501a405ad7ba23d0d38628152152509ce7011d6b2dd6676a"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira/python/jira_endpoints.py",
|
||||
"sha256": "dc8481f0a4a033bd1c81cf8ece0036157aef73aad7506eabf4c71390e2a0ec57"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/README.md",
|
||||
"sha256": "79b9d29a1dddfcfe05411637f3dfa72f5eac35ab25aab9bbbc037d41aa6fc18b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/config.yml",
|
||||
"sha256": "cd0b5db0dae014a69670dc8366df354624fd51fb04cf3009d0018e09fa0a01f0"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/mxcp-site.yml",
|
||||
"sha256": "d0c6a512ee85f5a58f8fd6b727e5014ec172f15245888a9f0def5dda4898bbc9"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/tools/cql_query.yml",
|
||||
"sha256": "f011f4fef76e95af6d5500261b920acd5c868087fb1a467895511f5b9d6da7cc"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/tools/get_page.yml",
|
||||
"sha256": "a236ae6c965769bdc5860f7cceb0b17cf268861ba675009546dbe32e70c59685"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/tools/get_children.yml",
|
||||
"sha256": "f23c90c6e568675d766ad845f48bac58760cd0115f1e59db2a94cbb435b4d3f2"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/tools/describe_page.yml",
|
||||
"sha256": "3eb376b26b85a7443012a7c4fc3fa6c0bdc124bbaffd7a21aeaca20dd7cfb622"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/tools/list_spaces.yml",
|
||||
"sha256": "f93d168b96d5fcc6d031d244ecbe11c487c53be466146dfbaa09c672aa5e137a"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/tools/search_pages.yml",
|
||||
"sha256": "fd05e6f5ded86cfef7ff855d773bd4126c6bb9e43ca09737cd761d5667ee423b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/plugins/mxcp_plugin_confluence/__init__.py",
|
||||
"sha256": "d5e66b8de3a915f485bfdcf2d4832c62da680a95c6c601ec2c2f073e9f7a08a6"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/sql/get_children.sql",
|
||||
"sha256": "08167e0b44e3aca9f054ebe3fcf402f5d8b18da5dd7e37e38dc67ab4b3caedbf"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/sql/cql_query.sql",
|
||||
"sha256": "b159a0b01fb2dcb16dd028cdcd05bc245a1e6f07dfc627ceb649f6c456e4e14f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/sql/get_page.sql",
|
||||
"sha256": "25f441196bde824db1321facd71e93323c8c5903766bf9fae0e46f58e57ef219"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/sql/search_pages.sql",
|
||||
"sha256": "fb443bed4a9fd834a53b09edf80602711d6ab6035859fa0c48e83d321dfb8361"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/sql/list_spaces.sql",
|
||||
"sha256": "f465cdca33663cca5ca2e5679978a3669d0b88578f885bf9c433d9f4baddae93"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/confluence/sql/describe_page.sql",
|
||||
"sha256": "d56d22ab4c2e68bcb1be97b4c83a1e153e7461f1f9819170e83c825ffe229182"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/README.md",
|
||||
"sha256": "2d3d1bfd9dc2a72d7735af9fdbe5b5ba835c9caeb7902208de7506c97da10d68"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/config.yml",
|
||||
"sha256": "93cc041c548fa586081cc7210a609c3797803578f964f7ce9c24853964bbf843"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/mxcp-site.yml",
|
||||
"sha256": "da34832c3e57b4d1c7594c141e09162e1c31f7a83f80cdd2b8e657207b1f7f09"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/get_sobject.yml",
|
||||
"sha256": "1619e8912ec931607584804f03d571e2fef26b113b2a2453076d0f3aa58df4ce"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/describe_sobject.yml",
|
||||
"sha256": "7afc1e46b7b17887f2cbaaa24ea4d318e15dd64f60f9d8f1c139da9b36c797bd"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/soql.yml",
|
||||
"sha256": "44adadf6590cc69e326ac8bfb3b58225a27914156ad2060afc7ce1400526b4f4"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/whoami.yml",
|
||||
"sha256": "c649d8402c117da349b005bc8e8cab4911f7b63f1f42fb8c7b7d4bfdf68fdb15"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/list_sobjects.yml",
|
||||
"sha256": "beb7d299358e81efec2079a9840797b995296e1a60d1dcfd7016ce4753810e0d"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/sosl.yml",
|
||||
"sha256": "fe53d81b7664ccb8adf2df62077eb8055e0d9a2ec9c717260b2ff8c59d733974"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/tools/search.yml",
|
||||
"sha256": "8d973d8c874f5779e40e4e2916ee7504c1910d67631da40179c030c4732a436e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce-oauth/python/salesforce_client.py",
|
||||
"sha256": "cb6d4747bcc350cc723dc66173191017ea8dfe9c9ecd109274a33a3309a491fc"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/README.md",
|
||||
"sha256": "6a54669282e11989e60a09eae615f617663c9fc03aa88d940d490c303432c448"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/.gitignore",
|
||||
"sha256": "d63240c974b32776149b49f1de2ef051213846340c5f769de85f0ef3c51bb732"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/mxcp-site.yml",
|
||||
"sha256": "7fd85d2a799e1c22310dc4c1df39edec03e35e4f13ceadfd3c5fb7ffe753da81"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/dbt_project.yml",
|
||||
"sha256": "2d50823c6160bd6ed4996bb575665fd9f018fc5eeeef1f7f3f2348012d051e44"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/models/covid_data.sql",
|
||||
"sha256": "dfb7ab7a8713985315349e9a9202c5a7afb464994da416159242cfffae5458b5"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/models/hospitalizations.sql",
|
||||
"sha256": "9379b1439f4daae9c446a9903e09efa81eb3de83389bb386a58181c35dfdf635"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/models/locations.sql",
|
||||
"sha256": "eab3e7b87c2d0181ad28bad6ecbe6e611a8bcd90ade83fe1b72eb465efbd70a6"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/models/sources.yml",
|
||||
"sha256": "0f78b23f49253d964a7df3408a668ff903e51503b576f1f11bbdeaf778020349"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/covid_owid/prompts/prompt.yml",
|
||||
"sha256": "6456683c982fba7b00b3ad001757c6f969898fbfa56d1243c67bb7f9765bcb40"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/README.md",
|
||||
"sha256": "53dccf841faf792eafe4389edc69ac36b1f617eee06ec15ea3e45bd40feacfbf"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/config.yml",
|
||||
"sha256": "b3091c55f34129f7538f3810177edd01b5b18ed6e1240bc9e56f58a6d691977c"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/mxcp-site.yml",
|
||||
"sha256": "5f398c932e941530c75c092ce652a94a52a40d44de081b5117f268c41d9e6fa7"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/tools/get_sobject.yml",
|
||||
"sha256": "bb5c86ff480bcb6a949934d209c5ecaa2e3f7dd475725be680c36e280fc00257"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/tools/describe_sobject.yml",
|
||||
"sha256": "6cb4ba330b019ff23576678c5c65faebe1cf17b65fbe645e338ea14a0d5527a4"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/tools/soql.yml",
|
||||
"sha256": "7813ceaeed5568c1bc360fa0e217eb5f02444a33f15d61df077a7eef1bc032e0"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/tools/list_sobjects.yml",
|
||||
"sha256": "209cd5654e2048e7e12f6fcc2face42ba4e62e677225daa8de582287a5838538"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/tools/sosl.yml",
|
||||
"sha256": "5df116c377e203266ddc698be7c6b86a1545646fe78a744b96b52c58afdf4c58"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/tools/search.yml",
|
||||
"sha256": "9b34ce01c2c0ed32ab4f7265aba143bb758c3caabaa75371650d36c7faacadef"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/salesforce/python/salesforce_endpoints.py",
|
||||
"sha256": "e64d385d2a69d2141e2208da3576d8456b356146f869cf0172b06efcf9eee790"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/keycloak/README.md",
|
||||
"sha256": "a864e7e52afa34ba462ce2301b8cc30a6be3fc930402fe10cb2687f5608cd25b"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/keycloak/config.yml",
|
||||
"sha256": "71bab5920e47c7aefad19dcff5496514bd5a7b46906add982cf433070fe03353"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/keycloak/mxcp-site.yml",
|
||||
"sha256": "b230eb5349590406c57d11955733984d19953d81d10f7c072684caa021acf04f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/keycloak/tools/get_user_info.yml",
|
||||
"sha256": "ccfab0d10c15e7e2e665f7daa3f2a495df007e434cc1fe284b2710623489818f"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/squirro/data/db-default.duckdb",
|
||||
"sha256": "a71a5d75c93d9308cfb1210f03b268b38bef17dea8aaf83224b034a4e68e5567"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/earthquakes/README.md",
|
||||
"sha256": "6d44a0948a4cfb4275241424e5b8068be6fde0e4bfb0911cd0545c3cec2cc8f8"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/earthquakes/mxcp-site.yml",
|
||||
"sha256": "6f03282179c556e6a379b57e586a9d7469ce5a78eff631170887cf54167d07e6"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/earthquakes/tools/tool.yml",
|
||||
"sha256": "116a967018aeeda99d78a69179e517bd0a6083eec2cd84fb84531bdd22f7387d"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/earthquakes/prompts/prompt.yml",
|
||||
"sha256": "ca9ab14b805132adad341a084303365f17e9273ec38282fc7fa6cc2d4812aa2a"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/README.md",
|
||||
"sha256": "da4f8ba9bf73932da57283bf231283c147771b290ff8794d59f3e111039a2013"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/config.yml",
|
||||
"sha256": "0cc077f3b1e961d04539a7d8c6dd08805a41db72278d17f76f128cb352a06f56"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/mxcp-site.yml",
|
||||
"sha256": "b4577dae8ce6eb230e4be6e92c0ca5803d0a2b399b693755e6370ec3ab6d8ee3"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/tools/get_current_user.yml",
|
||||
"sha256": "130ddc0ac8bbbd58d326abedf1d8271f9cc4178c11a5adbf7644fb314b1b9080"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/tools/get_user.yml",
|
||||
"sha256": "3a19ec60b803d62d1a2750cdc829d27bada5cdc227af9719e58176f1483d732e"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/tools/jql.yml",
|
||||
"sha256": "ade223047da84d046d5255561a8c646f2f92aa7a8dbc35540fa2b0c6283ed959"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/tools/list_projects.yml",
|
||||
"sha256": "ee8a9ffc1afeaed6d07b0db52df79f9fd4972bccd6795a9978cb013d341f7180"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/tools/get_project.yml",
|
||||
"sha256": "89f507bbfe477a568c7104d66eeea7f9006d27ac88cb84dd74c72adf6257f362"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/plugins/mxcp_plugin_jira_oauth/__init__.py",
|
||||
"sha256": "a39efdcabc2a5ead7ee04d2db68a3c482d44e88808ae4e9696ab196a1df30392"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/plugins/mxcp_plugin_jira_oauth/plugin.py",
|
||||
"sha256": "5251d7d2244f2f2fd9a7494be94c9e1de8146831adacb9269383107c193e06bd"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/sql/get_current_user.sql",
|
||||
"sha256": "d6960073044cdfde6a0a405bd34174bfaa5230960fd4a2bcb66b107679e375c0"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/sql/get_project.sql",
|
||||
"sha256": "f0c5dfda853632aad7e8fafcefafd739219187e08fcb70e2103dac6d217485ae"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/sql/list_projects.sql",
|
||||
"sha256": "dad64ae1a41283661648c3e7a9c2ea6dec81fabc3266b9bf4707d71785ca3810"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/sql/get_user.sql",
|
||||
"sha256": "7f8121d434d948c3840edaba1e9a657c32e6ba755181bf32c43d6f996b1fe750"
|
||||
},
|
||||
{
|
||||
"path": "skills/mxcp-expert/assets/project-templates/jira-oauth/sql/jql.sql",
|
||||
"sha256": "748503e32c0a115ba22affc62c6d6592042fdb827973582bce238bbe37f3e96e"
|
||||
}
|
||||
],
|
||||
"dirSha256": "e065970c2bf43e0c06482499a934af49e7573360266bb2341993ae9154ffbbc1"
|
||||
},
|
||||
"security": {
|
||||
"scannedAt": null,
|
||||
"scannerVersion": null,
|
||||
"flags": []
|
||||
}
|
||||
}
|
||||
273
skills/mxcp-expert/README.md
Normal file
273
skills/mxcp-expert/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# MXCP Expert Claude Skill
|
||||
|
||||
A comprehensive Claude skill for working with MXCP (Enterprise MCP Framework) - enabling you to build production-ready MCP servers with SQL and Python endpoints, complete with security, audit trails, and policy enforcement.
|
||||
|
||||
## What's Included
|
||||
|
||||
This skill provides complete guidance for:
|
||||
|
||||
- **Creating MXCP projects** - Initialize and structure production-ready MCP servers
|
||||
- **Endpoint development** - Build tools, resources, and prompts using SQL or Python
|
||||
- **Enterprise features** - Implement authentication, policies, and audit logging
|
||||
- **dbt integration** - Combine data transformation with MCP endpoints
|
||||
- **Quality assurance** - Validate, test, lint, and evaluate your endpoints
|
||||
- **Production deployment** - Monitor drift, track operations, and ensure security
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the `mxcp-expert.zip` file
|
||||
2. In Claude Desktop, go to Settings → Developer → Custom Skills
|
||||
3. Click "Add Skill" and upload the `mxcp-expert.zip` file
|
||||
4. The skill will be automatically available when working on MXCP projects
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
mxcp-expert/
|
||||
├── SKILL.md # Main skill file with quick reference
|
||||
├── assets/ # Project templates and resources
|
||||
│ ├── project-templates/ # Pre-built MXCP projects
|
||||
│ │ ├── google-calendar/ # OAuth integration examples
|
||||
│ │ ├── jira/ jira-oauth/ # Jira integrations
|
||||
│ │ ├── salesforce/ salesforce-oauth/ # Salesforce integrations
|
||||
│ │ ├── confluence/ # Confluence integration
|
||||
│ │ ├── python-demo/ # Python endpoint patterns
|
||||
│ │ ├── covid_owid/ # dbt data caching example
|
||||
│ │ ├── keycloak/ # SSO integration
|
||||
│ │ └── ... # More templates
|
||||
│ └── schemas/ # JSON Schema definitions for YAML validation
|
||||
│ ├── mxcp-site-schema-1.json # mxcp-site.yml validation
|
||||
│ ├── mxcp-config-schema-1.json # config.yml validation
|
||||
│ ├── tool-schema-1.json # Tool definition validation
|
||||
│ ├── resource-schema-1.json # Resource definition validation
|
||||
│ ├── prompt-schema-1.json # Prompt definition validation
|
||||
│ ├── eval-schema-1.json # Evaluation suite validation
|
||||
│ └── common-types-schema-1.json # Common type definitions
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── validate_yaml.py # YAML validation script
|
||||
└── references/ # Detailed documentation (23 files)
|
||||
├── tool-templates.md # Ready-to-use YAML templates for tools/resources/prompts
|
||||
├── project-selection-guide.md # Decision tree and template selection
|
||||
├── dbt-core-guide.md # Essential dbt knowledge (seeds, models, Python models)
|
||||
├── duckdb-essentials.md # DuckDB features and SQL extensions
|
||||
├── endpoint-patterns.md # Complete endpoint examples
|
||||
├── python-api.md # Python runtime API reference
|
||||
├── policies.md # Policy enforcement guide
|
||||
├── comprehensive-testing-guide.md # Complete testing strategies
|
||||
├── debugging-guide.md # Systematic debugging workflows
|
||||
├── error-handling-guide.md # Python error handling patterns
|
||||
├── dbt-patterns.md # Advanced dbt integration patterns
|
||||
├── database-connections.md # PostgreSQL, MySQL, SQLite, SQL Server
|
||||
├── excel-integration.md # Excel file handling with pandas and dbt
|
||||
├── claude-desktop.md # Claude Desktop setup guide
|
||||
├── cli-reference.md # Complete CLI documentation
|
||||
├── type-system.md # Type validation reference
|
||||
└── ... # 8 more specialized guides
|
||||
```
|
||||
|
||||
## When This Skill is Used
|
||||
|
||||
Claude will automatically use this skill for ANY mention or reference to MXCP, including when you:
|
||||
|
||||
- Mention "MXCP", "mxcp", "MXCP server", "MXCP project" in any context
|
||||
- Ask questions about MXCP or its features
|
||||
- Create, initialize, build, or set up MXCP projects
|
||||
- Edit or modify MXCP files (mxcp-site.yml, config.yml, tools/resources/prompts)
|
||||
- Define or work with MCP tools, resources, or prompts using MXCP
|
||||
- Implement SQL queries or Python endpoints for MXCP
|
||||
- Configure MXCP features (authentication, policies, audit logging, dbt integration)
|
||||
- Run MXCP commands (mxcp validate, mxcp test, mxcp serve, mxcp init, mxcp lint)
|
||||
- Test, validate, lint, or debug MXCP endpoints
|
||||
- Fix MXCP validation errors or troubleshoot issues
|
||||
- Deploy MXCP servers to production
|
||||
|
||||
## Quick Start Examples
|
||||
|
||||
### Example 1: CSV File to MCP Server
|
||||
|
||||
```
|
||||
User: "I need to connect my chat to a CSV file with customer data"
|
||||
|
||||
Claude: [Uses mxcp-expert skill]
|
||||
I'll help you create an MXCP server that queries your CSV file.
|
||||
|
||||
1. Initialize project:
|
||||
mkdir customer-mcp && cd customer-mcp
|
||||
mxcp init --bootstrap
|
||||
|
||||
2. Add your CSV as a dbt seed:
|
||||
cp customers.csv seeds/
|
||||
|
||||
3. Create schema definition (seeds/schema.yml):
|
||||
[Creates proper schema.yml with column types and tests]
|
||||
|
||||
4. Load data:
|
||||
dbt seed
|
||||
dbt test
|
||||
|
||||
5. Create query tool:
|
||||
[Creates tools/get_customers.yml with parameterized SQL]
|
||||
|
||||
6. Test and validate:
|
||||
mxcp validate && mxcp test
|
||||
mxcp serve
|
||||
|
||||
[Provides complete security-first implementation with validation]
|
||||
```
|
||||
|
||||
### Example 2: API Integration
|
||||
|
||||
```
|
||||
User: "Connect to Google Calendar API"
|
||||
|
||||
Claude: [Uses mxcp-expert skill]
|
||||
I'll use the google-calendar project template for OAuth integration.
|
||||
|
||||
1. Copy template:
|
||||
cp -r assets/project-templates/google-calendar calendar-mcp
|
||||
cd calendar-mcp
|
||||
|
||||
2. Configure OAuth in project config.yml:
|
||||
[Creates config.yml with OAuth settings in project directory]
|
||||
|
||||
3. Test authentication:
|
||||
mxcp serve
|
||||
[Explains OAuth flow]
|
||||
|
||||
[Provides complete working OAuth integration]
|
||||
```
|
||||
|
||||
### Example 3: Database Connection
|
||||
|
||||
```
|
||||
User: "Connect to my PostgreSQL production database"
|
||||
|
||||
Claude: [Uses mxcp-expert skill]
|
||||
I'll help you connect to your PostgreSQL database using DuckDB ATTACH.
|
||||
|
||||
1. Create project and config:
|
||||
mkdir postgres-mcp && cd postgres-mcp
|
||||
mxcp init --bootstrap
|
||||
|
||||
Create config.yml with database credentials (uses environment variables)
|
||||
|
||||
2. Create SQL tool with ATTACH:
|
||||
[Creates tools/query_customers.yml with PostgreSQL ATTACH statement]
|
||||
|
||||
3. Set credentials and test:
|
||||
export DB_HOST="localhost" DB_USER="readonly_user" DB_PASSWORD="xxx"
|
||||
mxcp validate && mxcp run tool query_customers
|
||||
|
||||
4. Alternative: Cache data with dbt for fast queries
|
||||
[Shows dbt source + model pattern to materialize data]
|
||||
|
||||
[Provides both direct query and cached approaches with security best practices]
|
||||
```
|
||||
|
||||
## Key Features Covered
|
||||
|
||||
### Endpoint Development
|
||||
- SQL tools for data queries
|
||||
- Python tools for complex logic
|
||||
- Resources for data access
|
||||
- Prompts with Jinja templates
|
||||
- Combined SQL + Python patterns
|
||||
|
||||
### Enterprise Features
|
||||
- OAuth authentication (GitHub, Google, Microsoft, etc.)
|
||||
- Policy-based access control with CEL expressions
|
||||
- Comprehensive audit logging (JSONL format)
|
||||
- Field-level data filtering and masking
|
||||
- User context testing
|
||||
|
||||
### Quality Assurance
|
||||
- Structure validation with `mxcp validate`
|
||||
- Functional testing with `mxcp test`
|
||||
- Metadata quality checks with `mxcp lint`
|
||||
- LLM behavior testing with `mxcp evals`
|
||||
|
||||
### dbt Integration
|
||||
- Data transformation pipelines
|
||||
- External data caching
|
||||
- Incremental model patterns
|
||||
- Data quality tests
|
||||
|
||||
### Production Operations
|
||||
- Drift detection and monitoring
|
||||
- Audit log querying and export
|
||||
- Multi-environment profiles
|
||||
- Secrets management (Vault, 1Password)
|
||||
- OpenTelemetry observability
|
||||
|
||||
## Core Principles
|
||||
|
||||
This skill prioritizes:
|
||||
|
||||
1. **Security First** - Authentication, authorization, parameterized queries, input validation
|
||||
2. **Robustness** - Error handling, type safety, data quality checks
|
||||
3. **Validity** - Schema compliance, structure validation
|
||||
4. **Testability** - Comprehensive test coverage
|
||||
5. **Testing** - Always validate/test/lint before deployment
|
||||
|
||||
## Mandatory Workflow
|
||||
|
||||
**To ensure MXCP servers always work correctly, the agent follows:**
|
||||
|
||||
1. **Build incrementally** - One tool/component at a time
|
||||
2. **Validate continuously** - `mxcp validate` after each change
|
||||
3. **Test before proceeding** - `mxcp test` must pass before next step
|
||||
4. **Verify manually** - Run actual tool with real data
|
||||
5. **Definition of Done** - ALL validation checks must pass
|
||||
|
||||
The agent will NEVER declare a project complete unless all validation, tests, and manual verification succeed.
|
||||
|
||||
## Documentation Coverage
|
||||
|
||||
The skill includes comprehensive documentation based on official MXCP docs:
|
||||
|
||||
**CRITICAL - Start Here**:
|
||||
- **build-and-validate-workflow.md** - MANDATORY workflow ensuring correctness
|
||||
- **minimal-working-examples.md** - Guaranteed-to-work examples (copy, test, customize)
|
||||
|
||||
**Essential Guides** (for most projects):
|
||||
- **project-selection-guide.md** - Decision trees, heuristics, when to use which approach
|
||||
- **dbt-core-guide.md** - dbt seeds, models, schema.yml (critical for CSV/data projects)
|
||||
- **duckdb-essentials.md** - DuckDB SQL features, CSV import, analytics
|
||||
- **excel-integration.md** - Excel file handling and pandas integration
|
||||
- **synthetic-data-patterns.md** - Generate test data with GENERATE_SERIES
|
||||
|
||||
**Detailed References**:
|
||||
- **endpoint-patterns.md** - Complete tool/resource/prompt examples
|
||||
- **python-api.md** - Python runtime API and library wrapping patterns
|
||||
- **testing-guide.md** - Comprehensive testing strategies
|
||||
- **policies.md** - Policy enforcement and security
|
||||
- **type-system.md** - Type validation rules
|
||||
- **cli-reference.md** - Complete CLI documentation
|
||||
- **claude-desktop.md** - Claude Desktop integration
|
||||
- **dbt-patterns.md** - Advanced dbt integration patterns
|
||||
|
||||
## About MXCP
|
||||
|
||||
MXCP is an enterprise-grade MCP (Model Context Protocol) framework that provides a structured methodology for building production AI applications:
|
||||
|
||||
1. **Data Quality First** - Start with dbt models and data contracts
|
||||
2. **Service Design** - Define types, security policies, and API contracts
|
||||
3. **Smart Implementation** - Choose SQL for data, Python for logic
|
||||
4. **Quality Assurance** - Validate, test, lint, and evaluate
|
||||
5. **Production Operations** - Monitor drift, track audits, ensure performance
|
||||
|
||||
## License
|
||||
|
||||
This skill compiles information from the MXCP project documentation.
|
||||
MXCP is released under the Business Source License 1.1 (BSL).
|
||||
|
||||
For more information about MXCP:
|
||||
- Website: https://mxcp.dev
|
||||
- GitHub: https://github.com/raw-labs/mxcp
|
||||
- Contact: mxcp@raw-labs.com
|
||||
|
||||
## Skill Version
|
||||
|
||||
Version: 1.0.0
|
||||
Created: October 2025
|
||||
Based on: MXCP documentation as of October 2025
|
||||
1059
skills/mxcp-expert/SKILL.md
Normal file
1059
skills/mxcp-expert/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
153
skills/mxcp-expert/assets/project-templates/confluence/README.md
Normal file
153
skills/mxcp-expert/assets/project-templates/confluence/README.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# MXCP Confluence Plugin Example
|
||||
|
||||
This example demonstrates how to use MXCP with Confluence data. It shows how to:
|
||||
- Create and use a custom MXCP plugin for Confluence integration
|
||||
- Query Confluence content using SQL
|
||||
- Combine Confluence data with other data sources
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin provides several UDFs that allow you to:
|
||||
- Search pages using keywords and CQL queries
|
||||
- Fetch page content and metadata
|
||||
- List child pages and spaces
|
||||
- Navigate the Confluence content hierarchy
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Creating an Atlassian API Token
|
||||
|
||||
**Important:** This plugin currently only supports API tokens **without scopes**. While Atlassian has introduced scoped API tokens, there are known compatibility issues when using scoped tokens with basic authentication that this plugin relies on.
|
||||
|
||||
To create an API token without scopes:
|
||||
|
||||
1. **Log in to your Atlassian account** at [https://id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
|
||||
2. **Verify your identity** (if prompted):
|
||||
- Atlassian may ask you to verify your identity before creating API tokens
|
||||
- Check your email for a one-time passcode and enter it when prompted
|
||||
|
||||
3. **Create the API token**:
|
||||
- Click **"Create API token"** (not "Create API token with scopes")
|
||||
- Enter a descriptive name for your token (e.g., "MXCP Confluence Integration")
|
||||
- Select an expiration date (tokens can last from 1 day to 1 year)
|
||||
- Click **"Create"**
|
||||
|
||||
4. **Copy and save your token**:
|
||||
- Click **"Copy to clipboard"** to copy the token
|
||||
- **Important:** Save this token securely (like in a password manager) as you won't be able to view it again
|
||||
- This token will be used as your "password" in the configuration below
|
||||
|
||||
### 2. User Configuration
|
||||
|
||||
Add the following to your MXCP user config (`~/.mxcp/config.yml`). You can use the example `config.yml` in this directory as a template:
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
|
||||
projects:
|
||||
confluence-demo:
|
||||
profiles:
|
||||
dev:
|
||||
plugin:
|
||||
config:
|
||||
confluence:
|
||||
url: "https://your-domain.atlassian.net/wiki"
|
||||
username: "your-email@example.com"
|
||||
password: "your-api-token" # Use the API token you created above
|
||||
```
|
||||
|
||||
**Configuration Notes:**
|
||||
- Replace `your-domain` with your actual Atlassian domain
|
||||
- Replace `your-email@example.com` with the email address of your Atlassian account
|
||||
- Replace `your-api-token` with the API token you created in step 1
|
||||
- The `password` field should contain your API token, not your actual Atlassian password
|
||||
|
||||
### 2. Site Configuration
|
||||
|
||||
Create an `mxcp-site.yml` file:
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
project: confluence-demo
|
||||
profile: dev
|
||||
plugin:
|
||||
- name: confluence
|
||||
module: mxcp_plugin_confluence
|
||||
config: confluence
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Search Pages
|
||||
```sql
|
||||
-- Search for pages containing specific text
|
||||
SELECT search_pages_confluence($query, $limit) as result;
|
||||
```
|
||||
|
||||
### Get Page
|
||||
```sql
|
||||
-- Fetch a page's content
|
||||
SELECT get_page_confluence($page_id) as result;
|
||||
```
|
||||
|
||||
### Get Children
|
||||
```sql
|
||||
-- List direct children of a page
|
||||
SELECT get_children_confluence($page_id) as result;
|
||||
```
|
||||
|
||||
### List Spaces
|
||||
```sql
|
||||
-- List all accessible spaces
|
||||
SELECT list_spaces_confluence() as result;
|
||||
```
|
||||
|
||||
### Describe Page
|
||||
```sql
|
||||
-- Show metadata about a page
|
||||
SELECT describe_page_confluence($page_id) as result;
|
||||
```
|
||||
|
||||
## Example Queries
|
||||
|
||||
1. Search and analyze page content:
|
||||
```sql
|
||||
WITH pages AS (
|
||||
SELECT * FROM search_pages_confluence('important documentation', 50)
|
||||
)
|
||||
SELECT
|
||||
p.title as page_title,
|
||||
p.space.name as space_name,
|
||||
p.version.number as version,
|
||||
p.metadata.created as created_date
|
||||
FROM pages p
|
||||
ORDER BY p.metadata.created DESC;
|
||||
```
|
||||
|
||||
## Plugin Development
|
||||
|
||||
The `mxcp_plugin_confluence` directory contains a complete MXCP plugin implementation that you can use as a reference for creating your own plugins. It demonstrates:
|
||||
|
||||
- Plugin class structure
|
||||
- Type conversion
|
||||
- UDF implementation
|
||||
- Configuration handling
|
||||
|
||||
## Running the Example
|
||||
|
||||
1. Set the `MXCP_CONFIG` environment variable to point to your config file:
|
||||
```bash
|
||||
export MXCP_CONFIG=/path/to/examples/confluence/config.yml
|
||||
```
|
||||
|
||||
2. Start the MXCP server:
|
||||
```bash
|
||||
mxcp serve
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Make sure to keep your API token secure and never commit it to version control.
|
||||
- The plugin requires proper authentication and API permissions to work with your Confluence instance.
|
||||
- All functions return JSON strings containing the requested data.
|
||||
@@ -0,0 +1,12 @@
|
||||
mxcp: 1
|
||||
|
||||
projects:
|
||||
confluence-demo:
|
||||
profiles:
|
||||
dev:
|
||||
plugin:
|
||||
config:
|
||||
confluence:
|
||||
url: "https://your-domain.atlassian.net/wiki"
|
||||
username: "your-email@example.com"
|
||||
password: "your-api-token"
|
||||
@@ -0,0 +1,7 @@
|
||||
mxcp: 1
|
||||
project: confluence-demo
|
||||
profile: dev
|
||||
plugin:
|
||||
- name: confluence
|
||||
module: mxcp_plugin_confluence
|
||||
config: confluence
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Confluence Plugin Implementation
|
||||
|
||||
This module provides UDFs for interacting with Atlassian Confluence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from atlassian import Confluence
|
||||
|
||||
from mxcp.plugins import MXCPBasePlugin, udf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MXCPPlugin(MXCPBasePlugin):
|
||||
"""Confluence plugin that provides content query functionality."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize the Confluence plugin.
|
||||
|
||||
Args:
|
||||
config: Plugin configuration containing Confluence API credentials
|
||||
Required keys:
|
||||
- url: The base URL of your Confluence instance
|
||||
- username: Your Atlassian username/email
|
||||
- password: Your Atlassian API token
|
||||
"""
|
||||
super().__init__(config)
|
||||
self.url = config.get("url", "")
|
||||
self.username = config.get("username", "")
|
||||
self.password = config.get("password", "")
|
||||
|
||||
if not all([self.url, self.username, self.password]):
|
||||
raise ValueError(
|
||||
"Confluence plugin requires url, username, and password in configuration"
|
||||
)
|
||||
|
||||
# Initialize Confluence client
|
||||
self.confluence = Confluence(
|
||||
url=self.url, username=self.username, password=self.password, cloud=True
|
||||
)
|
||||
|
||||
@udf
|
||||
def cql_query(
|
||||
self, query: str, space_key: Optional[str] = None, max_results: Optional[int] = 50
|
||||
) -> str:
|
||||
"""Execute a CQL query against Confluence.
|
||||
|
||||
Args:
|
||||
query: The CQL query string
|
||||
space_key: Optional space key to limit the search
|
||||
max_results: Maximum number of results to return (default: 50)
|
||||
|
||||
Returns:
|
||||
JSON string containing matching pages
|
||||
"""
|
||||
logger.info(
|
||||
"Executing CQL query: %s in space=%s with max_results=%s", query, space_key, max_results
|
||||
)
|
||||
|
||||
# Build the CQL query
|
||||
cql = query
|
||||
if space_key:
|
||||
cql = f'space = "{space_key}" AND {cql}'
|
||||
|
||||
# Execute the CQL query
|
||||
results = self.confluence.cql(cql=cql, limit=max_results, expand="version,metadata.labels")
|
||||
|
||||
# Transform the response to match our schema
|
||||
transformed_results = [
|
||||
{
|
||||
"id": page["content"]["id"],
|
||||
"title": page["content"]["title"],
|
||||
"space_key": page["content"]["space"]["key"],
|
||||
"url": f"{self.url}/wiki/spaces/{page['content']['space']['key']}/pages/{page['content']['id']}",
|
||||
"version": {
|
||||
"number": page["content"]["version"]["number"],
|
||||
"when": page["content"]["version"]["when"],
|
||||
},
|
||||
"last_modified": page["content"]["version"]["when"],
|
||||
"author": page["content"]["version"]["by"]["email"],
|
||||
"labels": [
|
||||
label["name"] for label in page["content"]["metadata"]["labels"]["results"]
|
||||
],
|
||||
}
|
||||
for page in results["results"]
|
||||
]
|
||||
|
||||
return json.dumps(transformed_results)
|
||||
|
||||
@udf
|
||||
def search_pages(self, query: str, limit: Optional[int] = 10) -> str:
|
||||
"""Search pages by keyword.
|
||||
|
||||
Args:
|
||||
query: Search string, e.g., 'onboarding guide'
|
||||
limit: Maximum number of results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
JSON string containing matching pages
|
||||
"""
|
||||
logger.info("Searching pages with query: %s, limit: %s", query, limit)
|
||||
|
||||
results = self.confluence.cql(cql=f'text ~ "{query}"', limit=limit, expand="version,space")
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
@udf
|
||||
def get_page(self, page_id: str) -> str:
|
||||
"""Fetch page content (storage format or rendered HTML).
|
||||
|
||||
Args:
|
||||
page_id: Confluence page ID
|
||||
|
||||
Returns:
|
||||
JSON string containing page content
|
||||
"""
|
||||
logger.info("Getting page content for ID: %s", page_id)
|
||||
|
||||
page = self.confluence.get_page_by_id(page_id=page_id, expand="body.storage,body.view")
|
||||
|
||||
return json.dumps(page)
|
||||
|
||||
@udf
|
||||
def get_children(self, page_id: str) -> str:
|
||||
"""List direct children of a page.
|
||||
|
||||
Args:
|
||||
page_id: Confluence page ID
|
||||
|
||||
Returns:
|
||||
JSON string containing child pages
|
||||
"""
|
||||
logger.info("Getting children for page ID: %s", page_id)
|
||||
|
||||
children = self.confluence.get_child_pages(page_id=page_id, expand="version,space")
|
||||
|
||||
return json.dumps(children)
|
||||
|
||||
@udf
|
||||
def list_spaces(self) -> str:
|
||||
"""Return all accessible spaces (by key and name).
|
||||
|
||||
Returns:
|
||||
JSON string containing list of spaces
|
||||
"""
|
||||
logger.info("Listing all spaces")
|
||||
|
||||
spaces = self.confluence.get_all_spaces(expand="description,metadata.labels")
|
||||
|
||||
return json.dumps(spaces)
|
||||
|
||||
@udf
|
||||
def describe_page(self, page_id: str) -> str:
|
||||
"""Show metadata about a page (title, author, updated, labels, etc).
|
||||
|
||||
Args:
|
||||
page_id: Confluence page ID
|
||||
|
||||
Returns:
|
||||
JSON string containing page metadata
|
||||
"""
|
||||
logger.info("Getting metadata for page ID: %s", page_id)
|
||||
|
||||
page = self.confluence.get_page_by_id(
|
||||
page_id=page_id, expand="version,space,metadata.labels"
|
||||
)
|
||||
|
||||
return json.dumps(page)
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Example CQL query endpoint
|
||||
SELECT cql_query_confluence($cql, $space_key, $limit) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Show metadata about a Confluence page
|
||||
SELECT describe_page_confluence($page_id) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- List direct children of a Confluence page
|
||||
SELECT get_children_confluence($page_id) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Get Confluence page content
|
||||
SELECT get_page_confluence($page_id) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- List all accessible Confluence spaces
|
||||
SELECT list_spaces_confluence() as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Search Confluence pages by keyword
|
||||
SELECT search_pages_confluence($query, $limit) as result;
|
||||
@@ -0,0 +1,66 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: cql_query
|
||||
description: "Execute a CQL query against Confluence"
|
||||
parameters:
|
||||
- name: cql
|
||||
type: string
|
||||
description: |
|
||||
The CQL query string to execute.
|
||||
Example: 'text ~ "important documentation"'
|
||||
examples: [
|
||||
'text ~ "important documentation"',
|
||||
'type = page AND space = "TEAM"',
|
||||
'label = "documentation"'
|
||||
]
|
||||
- name: space_key
|
||||
type: string
|
||||
description: |
|
||||
The space key to search in.
|
||||
Example: 'TEAM'
|
||||
examples: ["TEAM", "DOCS", "PROD"]
|
||||
- name: limit
|
||||
type: integer
|
||||
description: |
|
||||
Maximum number of results to return.
|
||||
Defaults to 10 if not specified.
|
||||
examples: [10, 20, 50]
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Page ID"
|
||||
title:
|
||||
type: string
|
||||
description: "Page title"
|
||||
space_key:
|
||||
type: string
|
||||
description: "Space key"
|
||||
url:
|
||||
type: string
|
||||
description: "Page URL"
|
||||
version:
|
||||
type: object
|
||||
properties:
|
||||
number:
|
||||
type: integer
|
||||
description: "Version number"
|
||||
when:
|
||||
type: string
|
||||
description: "Version timestamp"
|
||||
last_modified:
|
||||
type: string
|
||||
description: "Last modification timestamp"
|
||||
author:
|
||||
type: string
|
||||
description: "Page author"
|
||||
labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "Page labels"
|
||||
source:
|
||||
file: "../sql/cql_query.sql"
|
||||
@@ -0,0 +1,28 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: describe_page
|
||||
description: |
|
||||
Show metadata about a Confluence page.
|
||||
Returns a JSON string containing page details like title, author, update date, and labels.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Describe Page
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: page_id
|
||||
type: string
|
||||
description: |
|
||||
The ID of the page to describe.
|
||||
This is typically a numeric ID found in the page URL.
|
||||
examples: ["123456", "789012"]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing the page metadata.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/describe_page.sql"
|
||||
@@ -0,0 +1,28 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_children
|
||||
description: |
|
||||
List direct children of a Confluence page.
|
||||
Returns a JSON string containing the child pages.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Children
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: page_id
|
||||
type: string
|
||||
description: |
|
||||
The ID of the parent page.
|
||||
This is typically a numeric ID found in the page URL.
|
||||
examples: ["123456", "789012"]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing an array of child pages.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/get_children.sql"
|
||||
@@ -0,0 +1,28 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_page
|
||||
description: |
|
||||
Fetch a Confluence page's content.
|
||||
Returns a JSON string containing the page content in both storage format and rendered HTML.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Page
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: page_id
|
||||
type: string
|
||||
description: |
|
||||
The ID of the page to fetch.
|
||||
This is typically a numeric ID found in the page URL.
|
||||
examples: ["123456", "789012"]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing the page content.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/get_page.sql"
|
||||
@@ -0,0 +1,21 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: list_spaces
|
||||
description: |
|
||||
List all accessible Confluence spaces.
|
||||
Returns a JSON string containing space keys and names.
|
||||
type: tool
|
||||
annotations:
|
||||
title: List Spaces
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing an array of spaces.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/list_spaces.sql"
|
||||
@@ -0,0 +1,38 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: search_pages
|
||||
description: |
|
||||
Search Confluence pages by keyword.
|
||||
Returns a JSON string containing matching pages with their details.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Search Pages
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: |
|
||||
The search string to find in page content.
|
||||
This will search through page titles and content.
|
||||
examples: [
|
||||
"onboarding guide",
|
||||
"release notes",
|
||||
"API documentation"
|
||||
]
|
||||
- name: limit
|
||||
type: integer
|
||||
description: |
|
||||
Maximum number of results to return.
|
||||
Defaults to 10 if not specified.
|
||||
examples: [10, 20, 50]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing an array of matching pages.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/search_pages.sql"
|
||||
4
skills/mxcp-expert/assets/project-templates/covid_owid/.gitignore
vendored
Normal file
4
skills/mxcp-expert/assets/project-templates/covid_owid/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
target/
|
||||
dbt_packages/
|
||||
logs/
|
||||
149
skills/mxcp-expert/assets/project-templates/covid_owid/README.md
Normal file
149
skills/mxcp-expert/assets/project-templates/covid_owid/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# COVID-19 OWID Example
|
||||
|
||||
This example demonstrates how to use MXCP to create a COVID-19 data analysis API. It shows how to:
|
||||
- Fetch and cache COVID-19 data from Our World in Data (OWID)
|
||||
- Transform data using dbt and DuckDB
|
||||
- Create a natural language interface for data exploration using generic SQL tools
|
||||
|
||||
## Features
|
||||
|
||||
- **Comprehensive Data**: Global COVID-19 statistics from OWID
|
||||
- **Data Transformation**: dbt models for efficient querying
|
||||
- **Natural Language**: LLM-friendly query interface (prompt only)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure you have the required tools installed:
|
||||
```bash
|
||||
# Install MXCP and dependencies
|
||||
pip install mxcp dbt-core dbt-duckdb
|
||||
|
||||
# Option: Install in development mode
|
||||
cd /path/to/mxcp
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Running the Example
|
||||
|
||||
1. Navigate to the COVID example:
|
||||
```bash
|
||||
cd examples/covid_owid
|
||||
```
|
||||
|
||||
2. Initialize the data:
|
||||
```bash
|
||||
dbt deps
|
||||
dbt run
|
||||
```
|
||||
|
||||
3. Start the MCP server:
|
||||
```bash
|
||||
mxcp serve
|
||||
```
|
||||
|
||||
## 🔌 Claude Desktop Integration
|
||||
|
||||
To use this example with Claude Desktop:
|
||||
|
||||
### 1. Locate Claude's Configuration
|
||||
|
||||
Find your Claude Desktop configuration file:
|
||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
### 2. Configure the MCP Server
|
||||
|
||||
Add this configuration to your `claude_desktop_config.json`:
|
||||
|
||||
#### If you installed MXCP globally:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"covid": {
|
||||
"command": "mxcp",
|
||||
"args": ["serve", "--transport", "stdio"],
|
||||
"cwd": "/absolute/path/to/mxcp/examples/covid_owid"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### If you're using a virtual environment:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"covid": {
|
||||
"command": "/bin/bash",
|
||||
"args": [
|
||||
"-c",
|
||||
"cd /absolute/path/to/mxcp/examples/covid_owid && source ../../.venv/bin/activate && mxcp serve --transport stdio"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Replace `/absolute/path/to/mxcp` with the actual path to your MXCP installation.
|
||||
|
||||
### 3. Restart Claude Desktop
|
||||
|
||||
After saving the configuration, restart Claude Desktop to load the new MCP server.
|
||||
|
||||
### 4. Test the Integration
|
||||
|
||||
In Claude Desktop, try asking:
|
||||
- "Show me COVID-19 cases in the United States for 2022"
|
||||
- "Compare vaccination rates between France and Germany"
|
||||
- "What were the peak hospitalization rates in the UK?"
|
||||
|
||||
## 🛠️ Other MCP Clients
|
||||
|
||||
This example works with any MCP-compatible tool:
|
||||
- **mcp-cli**: Interactive command-line interface
|
||||
- **Custom integrations**: Build your own using the MCP specification
|
||||
|
||||
## Example Usage
|
||||
|
||||
The LLM can help you analyze:
|
||||
- Case numbers and death rates
|
||||
- Vaccination progress
|
||||
- Hospital occupancy
|
||||
- Regional comparisons
|
||||
- Policy effectiveness
|
||||
|
||||
All queries are handled through the generic SQL query interface. You can:
|
||||
- Use `list_tables` to see available tables
|
||||
- Use `get_table_schema` to inspect table structure
|
||||
- Use `execute_sql_query` to run custom SQL queries
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The example uses:
|
||||
- dbt for data transformation
|
||||
- DuckDB for efficient storage and querying
|
||||
- SQL analytics for complex calculations
|
||||
- Type-safe parameters for filtering
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
covid_owid/
|
||||
├── endpoints/ # MCP endpoint definitions
|
||||
│ └── prompt.yml # LLM system prompt (generic query interface only)
|
||||
├── models/ # dbt transformations
|
||||
│ ├── covid_data.sql # Main COVID-19 statistics
|
||||
│ ├── hospitalizations.sql # Hospital/ICU data
|
||||
│ └── locations.sql # Geographic data
|
||||
├── mxcp-site.yml # MCP configuration
|
||||
└── dbt_project.yml # dbt configuration
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [OWID COVID-19 Data](https://github.com/owid/covid-19-data) - Data source
|
||||
- [dbt Documentation](https://docs.getdbt.com/) - Data transformation
|
||||
- [DuckDB Documentation](https://duckdb.org/docs/) - Database engine
|
||||
- [MXCP Documentation](../../docs/quickstart.md) - MCP framework
|
||||
@@ -0,0 +1,19 @@
|
||||
analysis-paths:
|
||||
- analyses
|
||||
clean-targets:
|
||||
- target
|
||||
- dbt_packages
|
||||
config-version: 2
|
||||
macro-paths:
|
||||
- macros
|
||||
model-paths:
|
||||
- models
|
||||
name: covid_owid
|
||||
profile: covid_owid_default
|
||||
seed-paths:
|
||||
- seeds
|
||||
snapshot-paths:
|
||||
- snapshots
|
||||
target-path: target
|
||||
test-paths:
|
||||
- tests
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
select *
|
||||
from read_csv_auto('https://github.com/owid/covid-19-data/raw/master/public/data/owid-covid-data.csv')
|
||||
@@ -0,0 +1,5 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
select *
|
||||
|
||||
from read_csv_auto('https://github.com/owid/covid-19-data/raw/master/public/data/hospitalizations/covid-hospitalizations.csv')
|
||||
@@ -0,0 +1,5 @@
|
||||
{{ config(materialized='table') }}
|
||||
|
||||
select *
|
||||
|
||||
from read_csv_auto('https://github.com/owid/covid-19-data/raw/master/public/data/hospitalizations/locations.csv')
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 2
|
||||
|
||||
sources:
|
||||
- name: github_covid_data
|
||||
description: "COVID-19 data loaded directly from Our World in Data GitHub repository"
|
||||
meta:
|
||||
urls:
|
||||
owid_covid_data: "https://github.com/owid/covid-19-data/raw/master/public/data/owid-covid-data.csv"
|
||||
covid_hospitalizations: "https://github.com/owid/covid-19-data/raw/master/public/data/hospitalizations/covid-hospitalizations.csv"
|
||||
@@ -0,0 +1,5 @@
|
||||
mxcp: 1
|
||||
profile: default
|
||||
project: covid_owid
|
||||
sql_tools:
|
||||
enabled: true
|
||||
@@ -0,0 +1,75 @@
|
||||
mxcp: 1
|
||||
|
||||
prompt:
|
||||
name: "covid_data_analyst"
|
||||
description: "An AI assistant that analyzes and explains COVID-19 data from Our World in Data."
|
||||
tags: ["covid", "analysis", "health", "epidemiology"]
|
||||
messages:
|
||||
- role: system
|
||||
type: text
|
||||
prompt: |
|
||||
You are an expert COVID-19 data analyst with access to the Our World in Data (OWID) COVID-19 dataset. You can help users understand and analyze:
|
||||
|
||||
1. Case numbers, deaths, and testing data
|
||||
2. Vaccination rates and their impact
|
||||
3. Hospital and ICU occupancy rates
|
||||
4. Regional and country-specific trends
|
||||
5. Comparative analysis between countries
|
||||
6. Policy responses and their effectiveness
|
||||
|
||||
Data Exploration Tools:
|
||||
You have access to a generic query interface for exploring the COVID-19 data:
|
||||
- list_tables: View all available tables in the database
|
||||
- get_table_schema: Examine the structure and columns of any table
|
||||
- execute_sql_query: Run custom SQL queries for data analysis
|
||||
|
||||
These tools allow you to:
|
||||
1. Explore available data tables and their structure
|
||||
2. Create custom queries for specific analysis needs
|
||||
3. Perform complex aggregations and calculations
|
||||
4. Combine data from different tables
|
||||
5. Filter and sort data in any way needed
|
||||
6. Answer detailed or unusual questions from users
|
||||
|
||||
Available data includes:
|
||||
- Daily and cumulative case counts
|
||||
- Death rates and mortality statistics
|
||||
- Testing rates and positivity rates
|
||||
- Vaccination data (first, second doses, boosters)
|
||||
- Hospital and ICU admissions
|
||||
- Demographics and population metrics
|
||||
- Government response indicators
|
||||
|
||||
When responding:
|
||||
- Use list_tables and get_table_schema to understand available data
|
||||
- Create focused SQL queries that answer the specific question
|
||||
- Provide context for the numbers you present
|
||||
- Explain trends and potential factors affecting the data
|
||||
- Note any data limitations or gaps
|
||||
- Use clear, non-technical language when possible
|
||||
- Cite specific dates and sources
|
||||
- Acknowledge uncertainty where it exists
|
||||
- For SQL queries, explain your logic
|
||||
|
||||
Example Usage:
|
||||
1. Explore available tables:
|
||||
list_tables()
|
||||
|
||||
2. Understand table structure:
|
||||
get_table_schema("covid_data")
|
||||
|
||||
3. Custom analysis:
|
||||
execute_sql_query("
|
||||
SELECT
|
||||
location,
|
||||
date,
|
||||
new_cases,
|
||||
new_deaths,
|
||||
total_vaccinations
|
||||
FROM covid_data
|
||||
WHERE date >= '2021-01-01'
|
||||
AND location IN ('United States', 'United Kingdom')
|
||||
ORDER BY date DESC
|
||||
")
|
||||
|
||||
The data is sourced from Our World in Data's COVID-19 dataset, which is regularly updated and maintained by researchers at the University of Oxford.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Earthquakes Example
|
||||
|
||||
This example demonstrates how to use MXCP to create a real-time earthquake data API. It shows how to:
|
||||
- Query live earthquake data from the USGS API
|
||||
- Transform JSON data using SQL
|
||||
- Create type-safe endpoints for LLM consumption
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Data**: Fetches the latest earthquake data from USGS
|
||||
- **Type Safety**: Strong typing for LLM safety
|
||||
- **SQL Transformations**: Complex JSON parsing and data transformation
|
||||
- **Test Coverage**: Includes example tests
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure you have MXCP installed:
|
||||
```bash
|
||||
# Option 1: Install globally
|
||||
pip install mxcp
|
||||
|
||||
# Option 2: Install in development mode (if you cloned the repo)
|
||||
cd /path/to/mxcp
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Running the Example
|
||||
|
||||
1. Navigate to the earthquakes example:
|
||||
```bash
|
||||
cd examples/earthquakes
|
||||
```
|
||||
|
||||
2. Start the MCP server:
|
||||
```bash
|
||||
mxcp serve
|
||||
```
|
||||
|
||||
## 🔌 Claude Desktop Integration
|
||||
|
||||
To use this example with Claude Desktop:
|
||||
|
||||
### 1. Locate Claude's Configuration
|
||||
|
||||
Find your Claude Desktop configuration file:
|
||||
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
### 2. Configure the MCP Server
|
||||
|
||||
Add this configuration to your `claude_desktop_config.json`:
|
||||
|
||||
#### If you installed MXCP globally:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"earthquakes": {
|
||||
"command": "mxcp",
|
||||
"args": ["serve", "--transport", "stdio"],
|
||||
"cwd": "/absolute/path/to/mxcp/examples/earthquakes"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### If you're using a virtual environment:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"earthquakes": {
|
||||
"command": "/bin/bash",
|
||||
"args": [
|
||||
"-c",
|
||||
"cd /absolute/path/to/mxcp/examples/earthquakes && source ../../.venv/bin/activate && mxcp serve --transport stdio"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Replace `/absolute/path/to/mxcp` with the actual path to your MXCP installation.
|
||||
|
||||
### 3. Restart Claude Desktop
|
||||
|
||||
After saving the configuration, restart Claude Desktop to load the new MCP server.
|
||||
|
||||
### 4. Test the Integration
|
||||
|
||||
In Claude Desktop, try asking:
|
||||
- "Show me recent earthquakes above magnitude 5.0"
|
||||
- "What was the strongest earthquake in the last 24 hours?"
|
||||
- "List earthquakes near California"
|
||||
|
||||
Claude will automatically use the earthquake data tools to answer your questions.
|
||||
|
||||
## 🛠️ Other MCP Clients
|
||||
|
||||
This example also works with other MCP-compatible tools:
|
||||
|
||||
- **mcp-cli**: `pip install mcp-cli` then use the same server config
|
||||
- **Custom integrations**: Use the MCP specification to build your own client
|
||||
|
||||
## Example Usage
|
||||
|
||||
Ask your LLM to:
|
||||
- "Show me recent earthquakes above magnitude 5.0"
|
||||
- "What was the strongest earthquake in the last 24 hours?"
|
||||
- "List earthquakes near [location]"
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The example uses:
|
||||
- DuckDB's `read_json_auto` function to parse USGS GeoJSON
|
||||
- SQL window functions for data analysis
|
||||
- Type-safe parameters for filtering
|
||||
|
||||
For more details on:
|
||||
- Type system: See [Type System Documentation](../../docs/type-system.md)
|
||||
- SQL capabilities: See [Integrations Documentation](../../docs/integrations.md)
|
||||
- Configuration: See [Configuration Guide](../../docs/configuration.md)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
earthquakes/
|
||||
├── endpoints/
|
||||
│ └── tool.yml # Endpoint definition
|
||||
├── mxcp-site.yml # Project configuration
|
||||
└── tests/ # Example tests
|
||||
```
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Quickstart Guide](../../docs/quickstart.md) - Get started with MXCP
|
||||
- [CLI Reference](../../docs/cli.md) - Available commands
|
||||
- [Configuration](../../docs/configuration.md) - Project setup
|
||||
@@ -0,0 +1,7 @@
|
||||
mxcp: 1
|
||||
project: earthquake-api
|
||||
profile: prod
|
||||
profiles:
|
||||
prod:
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -0,0 +1,25 @@
|
||||
mxcp: 1
|
||||
|
||||
prompt:
|
||||
name: "summarize_earthquake_data"
|
||||
description: "Summarizes recent earthquake activity in plain English."
|
||||
tags: ["summary", "earthquake"]
|
||||
parameters:
|
||||
- name: top_event
|
||||
type: string
|
||||
description: "The most significant recent earthquake details as text"
|
||||
messages:
|
||||
- role: system
|
||||
type: text
|
||||
prompt: "You are an expert seismologist summarizing recent activity for the general public."
|
||||
- role: user
|
||||
type: text
|
||||
prompt: |
|
||||
Based on this recent event: {{ top_event }},
|
||||
please provide a brief summary of current seismic activity.
|
||||
Explain the significance of the event in terms of its magnitude and location.
|
||||
Include any additional details that might help the user understand the event.
|
||||
Use simple language and avoid technical terms.
|
||||
Keep it short and concise.
|
||||
Example output:
|
||||
"There was a magnitude 5.5 earthquake in San Francisco yesterday."
|
||||
@@ -0,0 +1,52 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: "query_recent_earthquakes"
|
||||
description: "Query earthquakes over a given magnitude threshold."
|
||||
tags: ["earthquake", "filter"]
|
||||
parameters:
|
||||
- name: min_magnitude
|
||||
type: number
|
||||
description: "Minimum magnitude"
|
||||
default: 2.5
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
source:
|
||||
code: |
|
||||
WITH raw AS (
|
||||
SELECT * FROM read_json_auto('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson')
|
||||
),
|
||||
features AS (
|
||||
SELECT
|
||||
feature
|
||||
FROM raw,
|
||||
UNNEST(features) AS feature
|
||||
),
|
||||
quakes AS (
|
||||
SELECT
|
||||
feature -> 'unnest' -> 'properties' -> 'mag' AS magnitude,
|
||||
feature -> 'unnest' -> 'properties' -> 'place' AS location,
|
||||
feature -> 'unnest' -> 'properties' -> 'time' AS time,
|
||||
feature -> 'unnest' -> 'geometry' -> 'coordinates' AS coords
|
||||
FROM features
|
||||
)
|
||||
SELECT
|
||||
CAST(magnitude AS DOUBLE) AS magnitude,
|
||||
location,
|
||||
CAST(time AS BIGINT) AS time,
|
||||
coords
|
||||
FROM quakes
|
||||
WHERE CAST(magnitude AS DOUBLE) >= $min_magnitude
|
||||
ORDER BY magnitude DESC;
|
||||
annotations:
|
||||
title: "Query Significant Earthquakes"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
tests:
|
||||
- name: filter-mag
|
||||
arguments:
|
||||
- key: min_magnitude
|
||||
value: 5.5
|
||||
@@ -0,0 +1,213 @@
|
||||
# Google Calendar OAuth Demo (Read-Only)
|
||||
|
||||
This example demonstrates how to create safe, read-only MCP tools that interact with Google Calendar using the MXCP OAuth authentication system with the Google Calendar API.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### 1. MXCP OAuth Authentication
|
||||
- Project-wide Google OAuth configuration
|
||||
- Automatic token management through MXCP authentication system
|
||||
- User authentication via standard OAuth 2.0 flow
|
||||
- Error handling for authentication failures
|
||||
|
||||
### 2. Google Calendar API Integration (Read-Only)
|
||||
- `whoami` - Display information about the current authenticated Google user
|
||||
- `list_calendars` - Retrieve all accessible calendars with filtering options
|
||||
- `get_calendar` - Get detailed information for a specific calendar
|
||||
- `list_events` - List events from a calendar with time filtering and pagination
|
||||
- `get_event` - Retrieve detailed information for a specific event
|
||||
- `search_events` - Search for events matching text queries
|
||||
- `get_freebusy` - Check availability across multiple calendars
|
||||
- Token-based API access using authenticated user context
|
||||
- **Safe Design**: Only read operations - no calendar or event modifications
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Google Account**: You need a Google account with Calendar access
|
||||
2. **Google Cloud Project**: Create a project in Google Cloud Console with Calendar API enabled
|
||||
3. **OAuth Credentials**: Create OAuth 2.0 credentials for your application
|
||||
4. **Python Dependencies**: The `google-api-python-client` and related libraries (automatically managed by MXCP)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create Google Cloud Project and Enable APIs
|
||||
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the Google Calendar API:
|
||||
- Go to **APIs & Services** → **Library**
|
||||
- Search for "Google Calendar API"
|
||||
- Click on it and press **Enable**
|
||||
|
||||
### 2. Configure OAuth Consent Screen (Required First)
|
||||
|
||||
1. In Google Cloud Console, go to **APIs & Services** → **OAuth consent screen**
|
||||
2. Configure the consent screen:
|
||||
- **User Type**: External (for testing) or Internal (for organization use)
|
||||
- **App Name**: "MXCP Google Calendar Integration" (or your preferred name)
|
||||
- **User Support Email**: Your email
|
||||
- **Developer Contact**: Your email
|
||||
3. **Add Scopes** (under "Data access" section):
|
||||
- Click "Add or Remove Scopes"
|
||||
- In the scope selection dialog, search for "calendar"
|
||||
- Find and select `https://www.googleapis.com/auth/calendar.readonly` (Calendar read-only access)
|
||||
- Click "Update" to save the scopes
|
||||
4. Save the consent screen configuration
|
||||
|
||||
**Note**: The scopes are configured in the OAuth Consent Screen, not when creating the Client ID. This is why you don't see scope options when creating credentials.
|
||||
|
||||
### 3. Create OAuth 2.0 Client ID
|
||||
|
||||
1. Go to **APIs & Services** → **Credentials**
|
||||
2. Click **Create Credentials** → **OAuth 2.0 Client IDs**
|
||||
3. Configure the client:
|
||||
- **Application Type**: Web application
|
||||
- **Name**: "MXCP Calendar Client" (or your preferred name)
|
||||
- **Authorized Redirect URIs**: Add based on your deployment:
|
||||
- **Local Development**: `http://localhost:8000/google/callback`
|
||||
- **Remote/Production**: `https://your-domain.com/google/callback` (replace with your actual server URL)
|
||||
4. Save and note down the **Client ID** and **Client Secret**
|
||||
|
||||
### 4. Configure Environment Variables
|
||||
|
||||
Set your Google OAuth credentials:
|
||||
```bash
|
||||
export GOOGLE_CLIENT_ID="your-client-id-from-google-cloud"
|
||||
export GOOGLE_CLIENT_SECRET="your-client-secret-from-google-cloud"
|
||||
```
|
||||
|
||||
### 5. Configure Callback URL for Your Deployment
|
||||
|
||||
The callback URL configuration depends on where your MXCP server will run:
|
||||
|
||||
#### Local Development
|
||||
For local development, the default configuration in `config.yml` uses `http://localhost:8000/google/callback`. This works when:
|
||||
- You're running MXCP locally on your development machine
|
||||
- Users authenticate from the same machine where MXCP is running
|
||||
|
||||
#### Remote/Production Deployment
|
||||
For remote servers or production deployments, you need to:
|
||||
|
||||
1. **Update config.yml**: Modify the callback URL:
|
||||
```yaml
|
||||
redirect_uris:
|
||||
- "https://your-domain.com/google/callback" # Your actual URL
|
||||
```
|
||||
|
||||
2. **Update base_url**: Set the correct base URL in your config:
|
||||
```yaml
|
||||
transport:
|
||||
http:
|
||||
base_url: https://your-domain.com # Your actual server URL
|
||||
```
|
||||
|
||||
3. **Configure OAuth Credentials**: Add the production callback URL to your Google Cloud OAuth credentials
|
||||
|
||||
**Important**:
|
||||
- The callback URL must be accessible from the user's browser, not just from your server
|
||||
- For production deployments, Google requires HTTPS for callback URLs
|
||||
- You can configure multiple callback URLs in your OAuth credentials to support both local development and production
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
google-calendar/
|
||||
├── mxcp-site.yml # Project metadata
|
||||
├── config.yml # Server and authentication configuration
|
||||
├── python/ # Python modules
|
||||
│ └── google_calendar_client.py # Google Calendar API implementations
|
||||
├── tools/ # Tool definitions (read-only)
|
||||
│ ├── whoami.yml # Current user information
|
||||
│ ├── list_calendars.yml # List accessible calendars
|
||||
│ ├── get_calendar.yml # Get calendar details
|
||||
│ ├── list_events.yml # List calendar events
|
||||
│ ├── get_event.yml # Get event details
|
||||
│ ├── search_events.yml # Search for events
|
||||
│ └── get_freebusy.yml # Check availability
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
1. **MXCP OAuth Integration**: Uses MXCP's built-in Google OAuth provider for secure authentication
|
||||
2. **User Context**: Access tokens are automatically managed and provided through `get_user_context()`
|
||||
3. **Token-based Authentication**: Google API client is initialized with OAuth tokens instead of service account credentials
|
||||
4. **Project-wide Configuration**: Authentication is configured at the project level in `config.yml`
|
||||
5. **Error Handling**: Comprehensive error handling for authentication and API failures
|
||||
6. **Type Safety**: Uses Python type hints and comprehensive error handling for data validation
|
||||
|
||||
## Running the Example
|
||||
|
||||
Once you've completed the setup above:
|
||||
|
||||
1. **Start MXCP**:
|
||||
```bash
|
||||
# From the examples/google-calendar directory:
|
||||
MXCP_CONFIG=config.yml mxcp serve
|
||||
```
|
||||
|
||||
2. **Connect your MCP client** (e.g., Claude Desktop) to the MXCP server
|
||||
|
||||
3. **Authenticate**: When the client first connects, you'll be redirected to Google to authorize the application
|
||||
|
||||
4. **Use the tools**: Once authenticated, you can use all the Google Calendar tools through your MCP client
|
||||
|
||||
## Example Usage
|
||||
|
||||
When you use the tools through an MCP client, you can:
|
||||
|
||||
### Get User Information
|
||||
```
|
||||
Use the whoami tool to see your Google profile information
|
||||
```
|
||||
|
||||
### Manage Calendars
|
||||
```
|
||||
List all your calendars, get details for specific calendars, and check which ones you can modify
|
||||
```
|
||||
|
||||
### View Calendar Events
|
||||
```
|
||||
- List events: "What's on my calendar this week?"
|
||||
- Search events: "Find all meetings with John"
|
||||
- Get event details: "Show me details for my 3 PM meeting"
|
||||
- View event information: "What meetings do I have with the marketing team?"
|
||||
```
|
||||
|
||||
### Check Availability
|
||||
```
|
||||
Use the freebusy tool to find available time slots across multiple calendars
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
- **"No user context available"**: User needs to authenticate first by running `mxcp serve` and completing OAuth flow
|
||||
- **"No Google access token found"**: Authentication was incomplete or token expired - re-authenticate
|
||||
- **OAuth Credentials Issues**: Verify your `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are correct
|
||||
- **Callback URL Mismatch**: Ensure the callback URL in your Google Cloud OAuth credentials matches where your MXCP server is accessible
|
||||
- **API Not Enabled**: Make sure the Google Calendar API is enabled in your Google Cloud project
|
||||
|
||||
### API Errors
|
||||
- **403 Forbidden**: Check that the Calendar API is enabled and your OAuth scopes include calendar access
|
||||
- **404 Not Found**: Verify calendar IDs and event IDs are correct and accessible to the authenticated user
|
||||
- **Rate Limiting**: Google Calendar API has rate limits - implement appropriate retry logic if needed
|
||||
|
||||
### OAuth Setup Issues
|
||||
- **Consent Screen**: Make sure your OAuth consent screen is properly configured with the correct scopes
|
||||
- **Redirect URI**: The redirect URI must exactly match your MXCP server's accessible address
|
||||
- **Scopes**: Ensure your OAuth configuration includes `https://www.googleapis.com/auth/calendar.readonly` scope
|
||||
|
||||
## Next Steps
|
||||
|
||||
This example demonstrates a comprehensive set of read-only Google Calendar integration tools. You could extend it with additional features like:
|
||||
- Advanced calendar filtering and search capabilities
|
||||
- Integration with other Google Workspace services (read-only)
|
||||
- Calendar analytics and reporting
|
||||
- Event pattern analysis and insights
|
||||
- Multi-calendar comparison and availability analysis
|
||||
|
||||
**Note**: This example is intentionally read-only for safety. If you need write operations (create, update, delete), you would need to:
|
||||
- Change the OAuth scope to `https://www.googleapis.com/auth/calendar` (full access)
|
||||
- Add appropriate write functions with proper validation and error handling
|
||||
- Implement additional safety measures and user confirmations
|
||||
@@ -0,0 +1,22 @@
|
||||
mxcp: 1
|
||||
transport:
|
||||
http:
|
||||
port: 8000
|
||||
host: 0.0.0.0
|
||||
# Set base_url to your server's public URL for production
|
||||
base_url: http://localhost:8000
|
||||
|
||||
projects:
|
||||
google-calendar:
|
||||
profiles:
|
||||
default:
|
||||
# OAuth Authentication Configuration
|
||||
auth:
|
||||
provider: google
|
||||
google:
|
||||
client_id: "${GOOGLE_CLIENT_ID}"
|
||||
client_secret: "${GOOGLE_CLIENT_SECRET}"
|
||||
scope: "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email"
|
||||
callback_path: "/google/callback"
|
||||
auth_url: "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
@@ -0,0 +1,3 @@
|
||||
mxcp: 1
|
||||
project: google-calendar
|
||||
profile: default
|
||||
@@ -0,0 +1,804 @@
|
||||
"""
|
||||
Google Calendar MCP client implementation using mxcp OAuth authentication.
|
||||
|
||||
This module provides Google Calendar API integration with:
|
||||
- OAuth 2.0 authentication via mxcp framework
|
||||
- Thread-safe client caching for performance
|
||||
- Simplified time handling for LLM consumption
|
||||
- Comprehensive error handling and user-friendly messages
|
||||
- Full type safety with Pydantic models
|
||||
"""
|
||||
|
||||
# Required for union syntax (|) in type annotations with runtime objects like threading.Lock
|
||||
# Without this, Python tries to evaluate "threading.Lock | None" at runtime, which fails
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import date, datetime, timezone
|
||||
from functools import wraps
|
||||
from typing import Any
|
||||
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import Resource, build # type: ignore[import-untyped]
|
||||
from googleapiclient.errors import HttpError # type: ignore[import-untyped]
|
||||
|
||||
from mxcp.runtime import on_init, on_shutdown
|
||||
from mxcp.sdk.auth.context import get_user_context
|
||||
|
||||
# =============================================================================
|
||||
# TIME CONVERSION UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _datetime_to_google_time(
|
||||
dt: datetime, all_day: bool = False, time_zone: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Convert datetime object to Google Calendar API time format.
|
||||
|
||||
Args:
|
||||
dt: Python datetime object (should be timezone-aware)
|
||||
all_day: Whether this represents an all-day event
|
||||
time_zone: Optional timezone override
|
||||
|
||||
Returns:
|
||||
Google API time object: {"dateTime": "...", "timeZone": "..."}
|
||||
or {"date": "YYYY-MM-DD"} for all-day events
|
||||
"""
|
||||
if all_day:
|
||||
# For all-day events, use date format
|
||||
return {"date": dt.date().isoformat()}
|
||||
else:
|
||||
# For timed events, use dateTime format
|
||||
time_obj = {"dateTime": dt.isoformat()}
|
||||
|
||||
# Add timezone if specified or if datetime has timezone info
|
||||
if time_zone:
|
||||
time_obj["timeZone"] = time_zone
|
||||
elif dt.tzinfo:
|
||||
# Extract timezone name from datetime object if possible
|
||||
tz_name = getattr(dt.tzinfo, "zone", None) or str(dt.tzinfo)
|
||||
if tz_name != "UTC" and "+" not in tz_name and "-" not in tz_name:
|
||||
time_obj["timeZone"] = tz_name
|
||||
else:
|
||||
# If no timezone info is available, try to get user's timezone
|
||||
try:
|
||||
user_timezone = _get_user_timezone()
|
||||
logger.warning(
|
||||
f"Datetime object has no timezone info, using user timezone: {user_timezone}"
|
||||
)
|
||||
time_obj["timeZone"] = user_timezone
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Datetime object has no timezone information and cannot determine timezone from calendar. "
|
||||
f"Please either: 1) Use timezone-aware datetime objects, or 2) Specify the time_zone parameter. "
|
||||
f"Original error: {e}"
|
||||
) from e
|
||||
|
||||
return time_obj
|
||||
|
||||
|
||||
def _get_user_timezone() -> str:
|
||||
"""Get the user's timezone from their primary calendar.
|
||||
|
||||
This function only tries to get the timezone from the user's primary calendar.
|
||||
If that fails, it raises an exception to force explicit timezone specification.
|
||||
|
||||
Results are cached to avoid repeated lookups.
|
||||
|
||||
Returns:
|
||||
IANA timezone identifier from user's primary calendar
|
||||
|
||||
Raises:
|
||||
ValueError: If timezone cannot be determined from primary calendar
|
||||
"""
|
||||
global _user_timezone_cache, _timezone_cache_lock
|
||||
|
||||
# Check cache first
|
||||
if _timezone_cache_lock and _user_timezone_cache:
|
||||
with _timezone_cache_lock:
|
||||
if _user_timezone_cache:
|
||||
return _user_timezone_cache
|
||||
|
||||
try:
|
||||
# Get timezone from user's primary calendar
|
||||
calendar_info = get_calendar("primary")
|
||||
if calendar_info and calendar_info.get("timeZone"):
|
||||
user_timezone: str = calendar_info["timeZone"]
|
||||
logger.debug(f"Using timezone from primary calendar: {user_timezone}")
|
||||
# Cache the result
|
||||
if _timezone_cache_lock:
|
||||
with _timezone_cache_lock:
|
||||
_user_timezone_cache = user_timezone
|
||||
return user_timezone
|
||||
else:
|
||||
raise ValueError("Primary calendar does not have timezone information")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get timezone from primary calendar: {e}")
|
||||
raise ValueError(
|
||||
"Cannot determine timezone from primary calendar. "
|
||||
"Please specify the time_zone parameter explicitly in your function call."
|
||||
) from e
|
||||
|
||||
|
||||
def _google_time_to_datetime(google_time: dict[str, Any]) -> tuple[datetime, bool]:
|
||||
"""
|
||||
Convert Google Calendar API time format to datetime object.
|
||||
|
||||
Args:
|
||||
google_time: Google API time object
|
||||
|
||||
Returns:
|
||||
Tuple of (datetime_object, is_all_day)
|
||||
"""
|
||||
if "date" in google_time:
|
||||
# All-day event - convert date to datetime at midnight UTC
|
||||
date_obj = date.fromisoformat(google_time["date"])
|
||||
dt = datetime.combine(date_obj, datetime.min.time(), tzinfo=timezone.utc)
|
||||
return dt, True
|
||||
elif "dateTime" in google_time:
|
||||
# Timed event - parse ISO datetime
|
||||
dt = datetime.fromisoformat(google_time["dateTime"])
|
||||
return dt, False
|
||||
else:
|
||||
raise ValueError(f"Invalid Google time format: {google_time}")
|
||||
|
||||
|
||||
def _convert_event_to_simplified(google_event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert Google Calendar API event to our simplified EventInfo format.
|
||||
|
||||
Args:
|
||||
google_event: Raw event from Google Calendar API
|
||||
|
||||
Returns:
|
||||
Event in simplified format matching EventInfo model
|
||||
"""
|
||||
# Convert start/end times
|
||||
start_dt, start_all_day = _google_time_to_datetime(google_event["start"])
|
||||
end_dt, end_all_day = _google_time_to_datetime(google_event["end"])
|
||||
|
||||
# Ensure both times have same all-day status
|
||||
all_day = start_all_day and end_all_day
|
||||
|
||||
simplified_event = {
|
||||
"id": google_event["id"],
|
||||
"summary": google_event.get("summary", ""),
|
||||
"description": google_event.get("description"),
|
||||
"location": google_event.get("location"),
|
||||
"start_time": start_dt,
|
||||
"end_time": end_dt,
|
||||
"all_day": all_day,
|
||||
"time_zone": google_event.get("start", {}).get("timeZone"),
|
||||
"status": google_event.get("status", "confirmed"),
|
||||
"htmlLink": google_event.get("htmlLink", ""),
|
||||
"created": datetime.fromisoformat(google_event["created"].replace("Z", "+00:00")),
|
||||
"updated": datetime.fromisoformat(google_event["updated"].replace("Z", "+00:00")),
|
||||
"calendar_id": google_event.get("calendarId", "unknown"), # Added by our code
|
||||
"etag": google_event.get("etag"),
|
||||
}
|
||||
|
||||
# Convert attendees if present
|
||||
if "attendees" in google_event:
|
||||
simplified_event["attendees"] = [
|
||||
{
|
||||
"email": att["email"],
|
||||
"displayName": att.get("displayName"),
|
||||
"responseStatus": att.get("responseStatus", "needsAction"),
|
||||
"optional": att.get("optional", False),
|
||||
"resource": att.get("resource", False),
|
||||
"comment": att.get("comment"),
|
||||
"additionalGuests": att.get("additionalGuests", 0),
|
||||
}
|
||||
for att in google_event["attendees"]
|
||||
]
|
||||
|
||||
# Convert creator/organizer if present
|
||||
for role in ["creator", "organizer"]:
|
||||
if role in google_event:
|
||||
simplified_event[role] = {
|
||||
"email": google_event[role].get("email"),
|
||||
"displayName": google_event[role].get("displayName"),
|
||||
"self": google_event[role].get("self", False),
|
||||
}
|
||||
|
||||
# Handle recurrence rules
|
||||
if "recurrence" in google_event:
|
||||
simplified_event["recurrence"] = google_event["recurrence"]
|
||||
|
||||
# Handle reminders
|
||||
if "reminders" in google_event:
|
||||
reminders = google_event["reminders"]
|
||||
simplified_event["reminders"] = {
|
||||
"useDefault": reminders.get("useDefault", True),
|
||||
"overrides": reminders.get("overrides"),
|
||||
}
|
||||
|
||||
# Handle transparency and visibility
|
||||
simplified_event["transparency"] = google_event.get("transparency", "opaque")
|
||||
simplified_event["visibility"] = google_event.get("visibility", "default")
|
||||
|
||||
return simplified_event
|
||||
|
||||
|
||||
def _convert_simplified_to_google_event(simplified_event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Convert simplified event format to Google Calendar API format.
|
||||
|
||||
Args:
|
||||
simplified_event: Event in our simplified format
|
||||
|
||||
Returns:
|
||||
Event in Google Calendar API format
|
||||
"""
|
||||
google_event = {
|
||||
"summary": simplified_event["summary"],
|
||||
"start": _datetime_to_google_time(
|
||||
simplified_event["start_time"],
|
||||
simplified_event.get("all_day", False),
|
||||
simplified_event.get("time_zone"),
|
||||
),
|
||||
"end": _datetime_to_google_time(
|
||||
simplified_event["end_time"],
|
||||
simplified_event.get("all_day", False),
|
||||
simplified_event.get("time_zone"),
|
||||
),
|
||||
}
|
||||
|
||||
# Add optional fields
|
||||
optional_fields = ["description", "location"]
|
||||
for field in optional_fields:
|
||||
if field in simplified_event and simplified_event[field] is not None:
|
||||
google_event[field] = simplified_event[field]
|
||||
|
||||
# Convert attendees
|
||||
if "attendees" in simplified_event and simplified_event["attendees"]:
|
||||
google_event["attendees"] = [
|
||||
(
|
||||
{"email": str(email)}
|
||||
if isinstance(email, str)
|
||||
else {
|
||||
"email": (
|
||||
str(email) if isinstance(email, str) else str(attendee.get("email", ""))
|
||||
),
|
||||
"displayName": attendee.get("displayName"),
|
||||
"optional": attendee.get("optional", False),
|
||||
"resource": attendee.get("resource", False),
|
||||
"comment": attendee.get("comment"),
|
||||
"additionalGuests": attendee.get("additionalGuests", 0),
|
||||
}
|
||||
)
|
||||
for email in simplified_event["attendees"]
|
||||
for attendee in [email if isinstance(email, dict) else {"email": email}]
|
||||
]
|
||||
|
||||
# Add transparency and visibility if specified
|
||||
if "transparency" in simplified_event:
|
||||
google_event["transparency"] = simplified_event["transparency"]
|
||||
if "visibility" in simplified_event:
|
||||
google_event["visibility"] = simplified_event["visibility"]
|
||||
if "recurrence" in simplified_event:
|
||||
google_event["recurrence"] = simplified_event["recurrence"]
|
||||
|
||||
# Add reminders if specified
|
||||
if "reminders" in simplified_event and simplified_event["reminders"]:
|
||||
google_event["reminders"] = simplified_event["reminders"]
|
||||
|
||||
return google_event
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# THREAD-SAFE CLIENT CACHING
|
||||
# =============================================================================
|
||||
|
||||
# Thread-safe cache for Google Calendar service clients
|
||||
_client_cache: dict[str, Resource] | None = None
|
||||
_cache_lock: threading.Lock | None = None
|
||||
|
||||
# Global timezone cache to avoid repeated lookups
|
||||
_user_timezone_cache: str | None = None
|
||||
_timezone_cache_lock: threading.Lock | None = None
|
||||
|
||||
|
||||
@on_init
|
||||
def init_client_cache() -> None:
|
||||
"""Initialize the Google Calendar client cache and timezone cache."""
|
||||
global _client_cache, _cache_lock, _timezone_cache_lock
|
||||
_client_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_timezone_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@on_shutdown
|
||||
def clear_client_cache() -> None:
|
||||
"""Clear the Google Calendar client cache and timezone cache."""
|
||||
global _client_cache, _cache_lock, _user_timezone_cache, _timezone_cache_lock
|
||||
_client_cache = None
|
||||
_cache_lock = None
|
||||
_user_timezone_cache = None
|
||||
_timezone_cache_lock = None
|
||||
|
||||
|
||||
def _get_cache_key(context: Any) -> str | None:
|
||||
"""Generate cache key based on user context."""
|
||||
if not context:
|
||||
return None
|
||||
|
||||
# Use user ID as cache key for per-user client isolation
|
||||
user_id = getattr(context, "user_id", None) or getattr(context, "id", None)
|
||||
if user_id:
|
||||
return f"gcal:{user_id}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_google_credentials() -> Credentials:
|
||||
"""Get Google OAuth credentials from mxcp user context."""
|
||||
context = get_user_context()
|
||||
if not context or not context.external_token:
|
||||
raise ValueError("No user context available. User must be authenticated.")
|
||||
|
||||
# Create Google credentials object with OAuth token
|
||||
credentials = Credentials(token=context.external_token) # type: ignore[no-untyped-call]
|
||||
return credentials
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LOGGING & ERROR HANDLING
|
||||
# =============================================================================
|
||||
|
||||
# Set up comprehensive logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def with_session_retry(func: Any) -> Any:
|
||||
"""
|
||||
Decorator for handling OAuth token refresh and API errors with user-friendly messages.
|
||||
|
||||
Wraps functions to automatically handle:
|
||||
- OAuth token refresh failures (RefreshError)
|
||||
- Google API HTTP errors with specific status codes
|
||||
- Client cache invalidation on auth failures
|
||||
- Comprehensive error logging for debugging
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
logger.info(f"Executing {func.__name__} with args={args}, kwargs={kwargs}")
|
||||
result = func(*args, **kwargs)
|
||||
logger.info(f"Successfully completed {func.__name__}")
|
||||
return result
|
||||
|
||||
except RefreshError as e:
|
||||
# OAuth token has expired and cannot be refreshed
|
||||
logger.warning(f"OAuth token refresh failed in {func.__name__}: {e}")
|
||||
clear_client_cache() # Clear cache to force re-authentication
|
||||
error_msg = (
|
||||
"Your Google Calendar access has expired. Please re-authenticate to continue."
|
||||
)
|
||||
logger.error(f"Authentication error: {error_msg}")
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
except HttpError as e:
|
||||
# Handle specific Google API errors with detailed logging
|
||||
status = e.resp.status
|
||||
error_details = str(e)
|
||||
logger.error(
|
||||
f"Google API HttpError in {func.__name__}: status={status}, details={error_details}"
|
||||
)
|
||||
|
||||
# Clear cache on authentication errors
|
||||
if status in [401, 403]:
|
||||
context = get_user_context()
|
||||
cache_key = _get_cache_key(context)
|
||||
if cache_key and _cache_lock and _client_cache:
|
||||
with _cache_lock:
|
||||
_client_cache.pop(cache_key, None)
|
||||
|
||||
# Just forward the original Google API error - it's already clear and actionable
|
||||
raise ValueError(f"Google Calendar API error: {error_details}") from e
|
||||
|
||||
# ValidationError removed since we no longer use Pydantic models
|
||||
|
||||
except ValueError as e:
|
||||
# Re-raise ValueError (these are user-friendly messages)
|
||||
logger.warning(f"User error in {func.__name__}: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
# Catch-all for unexpected errors
|
||||
logger.error(
|
||||
f"Unexpected error in {func.__name__}: {type(e).__name__}: {e}", exc_info=True
|
||||
)
|
||||
raise ValueError(f"An unexpected error occurred: {str(e)}") from e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_google_calendar_client() -> Resource:
|
||||
"""
|
||||
Get cached Google Calendar API client or create new one with OAuth authentication.
|
||||
|
||||
Uses per-user caching for performance and proper multi-user isolation.
|
||||
"""
|
||||
try:
|
||||
# Get authenticated user context
|
||||
context = get_user_context()
|
||||
if not context:
|
||||
raise ValueError("No user context available. User must be authenticated.")
|
||||
|
||||
# Check cache first
|
||||
cache_key = _get_cache_key(context)
|
||||
if cache_key and _cache_lock and _client_cache:
|
||||
with _cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
logging.info("Using cached Google Calendar client")
|
||||
return _client_cache[cache_key]
|
||||
|
||||
logging.info("Creating new Google Calendar client")
|
||||
# Create new authenticated client
|
||||
credentials = _get_google_credentials()
|
||||
service = build(
|
||||
serviceName="calendar",
|
||||
version="v3",
|
||||
credentials=credentials,
|
||||
cache_discovery=True, # Cache API discovery documents
|
||||
num_retries=3, # Retry transient failures
|
||||
)
|
||||
|
||||
# Cache the client for this user
|
||||
if cache_key and _cache_lock and _client_cache:
|
||||
with _cache_lock:
|
||||
_client_cache[cache_key] = service
|
||||
|
||||
return service
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to initialize Google Calendar client: {str(e)}") from e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MCP TOOL FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def whoami() -> dict[str, Any]:
|
||||
"""
|
||||
Get information about the currently authenticated user.
|
||||
|
||||
Returns:
|
||||
UserInfo object with user profile data from Google OAuth
|
||||
|
||||
Note:
|
||||
Uses OAuth profile information, no additional API calls required
|
||||
"""
|
||||
context = get_user_context()
|
||||
if not context:
|
||||
raise ValueError("No user context available. User must be authenticated.")
|
||||
|
||||
# Extract user information from OAuth profile
|
||||
raw_profile = context.raw_profile or {}
|
||||
|
||||
# Return plain dictionary following standard MXCP pattern
|
||||
return {
|
||||
"id": raw_profile.get("sub") or raw_profile.get("id", "unknown"),
|
||||
"email": raw_profile.get("email", "unknown@example.com"),
|
||||
"name": raw_profile.get("name", "Unknown User"),
|
||||
"given_name": raw_profile.get("given_name"),
|
||||
"family_name": raw_profile.get("family_name"),
|
||||
"picture": raw_profile.get("picture"),
|
||||
"locale": raw_profile.get("locale"),
|
||||
"verified_email": raw_profile.get("email_verified"),
|
||||
}
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def list_calendars(
|
||||
show_hidden: bool = False,
|
||||
show_deleted: bool = False,
|
||||
max_results: int = 100,
|
||||
min_access_role: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all calendars accessible to the authenticated user.
|
||||
|
||||
Args:
|
||||
show_hidden: Include hidden calendars in results
|
||||
show_deleted: Include deleted calendars in results
|
||||
max_results: Maximum number of calendars to return (1-250)
|
||||
min_access_role: Filter by minimum access level
|
||||
|
||||
Returns:
|
||||
List of CalendarInfo objects with user's accessible calendars
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated or parameters are invalid
|
||||
"""
|
||||
service = _get_google_calendar_client()
|
||||
|
||||
# Build parameters
|
||||
params: dict[str, Any] = {
|
||||
"maxResults": min(max_results, 250),
|
||||
"showHidden": show_hidden,
|
||||
"showDeleted": show_deleted,
|
||||
}
|
||||
|
||||
if min_access_role:
|
||||
params["minAccessRole"] = min_access_role
|
||||
|
||||
# Execute API call
|
||||
result = service.calendarList().list(**params).execute()
|
||||
|
||||
# Return plain dictionaries following standard MXCP pattern
|
||||
calendars = []
|
||||
for cal in result.get("items", []):
|
||||
calendar_dict = {
|
||||
"id": cal["id"],
|
||||
"summary": cal.get("summary", ""),
|
||||
"description": cal.get("description"),
|
||||
"timeZone": cal.get("timeZone", "UTC"),
|
||||
"accessRole": cal.get("accessRole", "reader"),
|
||||
"primary": cal.get("primary", False),
|
||||
"backgroundColor": cal.get("backgroundColor"),
|
||||
"foregroundColor": cal.get("foregroundColor"),
|
||||
"selected": cal.get("selected", False),
|
||||
"hidden": cal.get("hidden", False),
|
||||
"defaultReminders": cal.get("defaultReminders"),
|
||||
}
|
||||
calendars.append(calendar_dict)
|
||||
|
||||
return calendars
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def get_calendar(calendar_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get detailed information for a specific calendar.
|
||||
|
||||
Args:
|
||||
calendar_id: Calendar identifier or 'primary' for main calendar
|
||||
|
||||
Returns:
|
||||
CalendarInfo object with calendar details
|
||||
|
||||
Raises:
|
||||
ValueError: If calendar_id is invalid or user lacks access
|
||||
"""
|
||||
service = _get_google_calendar_client()
|
||||
|
||||
try:
|
||||
result = service.calendarList().get(calendarId=calendar_id).execute()
|
||||
except HttpError as e:
|
||||
if e.resp.status == 404:
|
||||
raise ValueError(
|
||||
f"Calendar '{calendar_id}' not found or you don't have access to it"
|
||||
) from e
|
||||
raise
|
||||
|
||||
# Return plain dictionary following standard MXCP pattern
|
||||
return {
|
||||
"id": result["id"],
|
||||
"summary": result.get("summary", ""),
|
||||
"description": result.get("description"),
|
||||
"timeZone": result.get("timeZone", "UTC"),
|
||||
"accessRole": result.get("accessRole", "reader"),
|
||||
"primary": result.get("primary", False),
|
||||
"backgroundColor": result.get("backgroundColor"),
|
||||
"foregroundColor": result.get("foregroundColor"),
|
||||
"selected": result.get("selected", False),
|
||||
"hidden": result.get("hidden", False),
|
||||
"defaultReminders": result.get("defaultReminders"),
|
||||
}
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def list_events(
|
||||
calendar_id: str = "primary",
|
||||
time_min: datetime | None = None,
|
||||
time_max: datetime | None = None,
|
||||
max_results: int = 250,
|
||||
single_events: bool = True,
|
||||
order_by: str = "startTime",
|
||||
page_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List events from a specific calendar with optional time filtering.
|
||||
|
||||
Args:
|
||||
calendar_id: Calendar to query ('primary' or specific calendar ID)
|
||||
time_min: Lower bound for event start times (inclusive)
|
||||
time_max: Upper bound for event start times (exclusive)
|
||||
max_results: Maximum number of events to return (1-2500)
|
||||
single_events: Whether to expand recurring events into instances
|
||||
order_by: Sort order - 'startTime' or 'updated'
|
||||
page_token: Token for pagination
|
||||
|
||||
Returns:
|
||||
EventSearchResult with events and pagination info
|
||||
"""
|
||||
service = _get_google_calendar_client()
|
||||
|
||||
# Build parameters
|
||||
params = {
|
||||
"calendarId": calendar_id,
|
||||
"maxResults": min(max_results, 2500),
|
||||
"singleEvents": single_events,
|
||||
"orderBy": order_by,
|
||||
}
|
||||
|
||||
if time_min:
|
||||
params["timeMin"] = time_min.isoformat()
|
||||
if time_max:
|
||||
params["timeMax"] = time_max.isoformat()
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
|
||||
# Execute API call
|
||||
result = service.events().list(**params).execute()
|
||||
|
||||
# Convert events to simplified format
|
||||
events = []
|
||||
for event in result.get("items", []):
|
||||
event["calendarId"] = calendar_id # Add calendar_id to event
|
||||
simplified_event = _convert_event_to_simplified(event)
|
||||
events.append(simplified_event)
|
||||
|
||||
# Return plain dictionary following standard MXCP pattern
|
||||
return {
|
||||
"events": events,
|
||||
"next_page_token": result.get("nextPageToken"),
|
||||
"total_results": len(events),
|
||||
}
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def get_event(calendar_id: str, event_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Retrieve detailed information for a specific event.
|
||||
|
||||
Args:
|
||||
calendar_id: Calendar containing the event
|
||||
event_id: Event identifier
|
||||
|
||||
Returns:
|
||||
EventInfo object with complete event details
|
||||
|
||||
Raises:
|
||||
ValueError: If event not found or user lacks access
|
||||
"""
|
||||
service = _get_google_calendar_client()
|
||||
|
||||
try:
|
||||
result = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
|
||||
except HttpError as e:
|
||||
if e.resp.status == 404:
|
||||
raise ValueError(f"Event '{event_id}' not found in calendar '{calendar_id}'") from e
|
||||
raise
|
||||
|
||||
# Add calendar_id to event
|
||||
result["calendarId"] = calendar_id
|
||||
simplified_event = _convert_event_to_simplified(result)
|
||||
|
||||
return simplified_event
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def search_events(
|
||||
q: str,
|
||||
calendar_id: str = "primary",
|
||||
time_min: datetime | None = None,
|
||||
time_max: datetime | None = None,
|
||||
max_results: int = 250,
|
||||
page_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search for events matching a text query.
|
||||
|
||||
Args:
|
||||
q: Free text search query (searches title, description, location, attendees)
|
||||
calendar_id: Calendar to search ('primary' or specific calendar ID)
|
||||
time_min: Earliest event start time to include
|
||||
time_max: Latest event start time to include
|
||||
max_results: Maximum number of events to return
|
||||
page_token: Token for pagination
|
||||
|
||||
Returns:
|
||||
EventSearchResult with matching events and pagination info
|
||||
"""
|
||||
service = _get_google_calendar_client()
|
||||
|
||||
# Build parameters
|
||||
params = {
|
||||
"calendarId": calendar_id,
|
||||
"q": q,
|
||||
"maxResults": min(max_results, 2500),
|
||||
"singleEvents": True,
|
||||
"orderBy": "startTime",
|
||||
}
|
||||
|
||||
if time_min:
|
||||
params["timeMin"] = time_min.isoformat()
|
||||
if time_max:
|
||||
params["timeMax"] = time_max.isoformat()
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
|
||||
# Execute API call
|
||||
result = service.events().list(**params).execute()
|
||||
|
||||
# Convert events to simplified format
|
||||
events = []
|
||||
for event in result.get("items", []):
|
||||
event["calendarId"] = calendar_id # Add calendar_id to event
|
||||
simplified_event = _convert_event_to_simplified(event)
|
||||
events.append(simplified_event)
|
||||
|
||||
# Return plain dictionary following standard MXCP pattern
|
||||
return {
|
||||
"events": events,
|
||||
"next_page_token": result.get("nextPageToken"),
|
||||
"total_results": len(events),
|
||||
}
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def get_freebusy(calendar_ids: list[str], time_min: datetime, time_max: datetime) -> dict[str, Any]:
|
||||
"""
|
||||
Check free/busy status across multiple calendars.
|
||||
|
||||
Args:
|
||||
calendar_ids: List of calendar IDs to check (use 'primary' for main calendar)
|
||||
time_min: Start time for availability check
|
||||
time_max: End time for availability check
|
||||
|
||||
Returns:
|
||||
FreeBusyResponse with busy periods for each calendar
|
||||
|
||||
Note:
|
||||
Useful for finding meeting slots and checking availability before scheduling
|
||||
"""
|
||||
if time_min >= time_max:
|
||||
raise ValueError("time_max must be after time_min")
|
||||
|
||||
service = _get_google_calendar_client()
|
||||
|
||||
# Build request
|
||||
request_body = {
|
||||
"timeMin": time_min.isoformat(),
|
||||
"timeMax": time_max.isoformat(),
|
||||
"items": [{"id": cal_id} for cal_id in calendar_ids],
|
||||
}
|
||||
|
||||
# Execute API call
|
||||
result = service.freebusy().query(body=request_body).execute()
|
||||
|
||||
# Convert to plain dictionary format following standard MXCP pattern
|
||||
calendars = []
|
||||
for calendar_id in calendar_ids:
|
||||
calendar_data = result.get("calendars", {}).get(calendar_id, {})
|
||||
|
||||
# Convert busy times to plain dictionaries
|
||||
busy_times = []
|
||||
for busy_period in calendar_data.get("busy", []):
|
||||
busy_times.append(
|
||||
{
|
||||
"start": busy_period["start"],
|
||||
"end": busy_period["end"],
|
||||
}
|
||||
)
|
||||
|
||||
calendars.append(
|
||||
{"calendar_id": calendar_id, "busy": busy_times, "errors": calendar_data.get("errors")}
|
||||
)
|
||||
|
||||
return {
|
||||
"time_min": time_min.isoformat(),
|
||||
"time_max": time_max.isoformat(),
|
||||
"calendars": calendars,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: get_calendar
|
||||
title: Get Calendar Details
|
||||
description: |
|
||||
Get detailed information for a specific calendar by ID.
|
||||
Returns calendar metadata including timezone, access role, and display properties.
|
||||
|
||||
Example usage:
|
||||
- "Get details for my primary calendar"
|
||||
- "Show me information about the work@company.com calendar"
|
||||
- "What timezone is my calendar set to?"
|
||||
tags:
|
||||
- google-calendar
|
||||
- calendars
|
||||
- get
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: calendar_id
|
||||
type: string
|
||||
description: "Calendar identifier or 'primary' for main calendar"
|
||||
default: "primary"
|
||||
examples: ["primary", "work@company.com", "team@company.com"]
|
||||
return:
|
||||
type: object
|
||||
description: "Calendar details and metadata"
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Calendar identifier"
|
||||
summary:
|
||||
type: string
|
||||
description: "Calendar name/title"
|
||||
description:
|
||||
type: string
|
||||
description: "Calendar description"
|
||||
timeZone:
|
||||
type: string
|
||||
description: "IANA timezone identifier"
|
||||
accessRole:
|
||||
type: string
|
||||
description: "User's access level"
|
||||
primary:
|
||||
type: boolean
|
||||
description: "Whether this is user's primary calendar"
|
||||
backgroundColor:
|
||||
type: string
|
||||
description: "Background color hex code"
|
||||
foregroundColor:
|
||||
type: string
|
||||
description: "Foreground color hex code"
|
||||
selected:
|
||||
type: boolean
|
||||
description: "Whether calendar is selected in UI"
|
||||
hidden:
|
||||
type: boolean
|
||||
description: "Whether calendar is hidden from list"
|
||||
required: ["id", "summary", "timeZone", "accessRole"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
@@ -0,0 +1,96 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: get_event
|
||||
title: Get Event Details
|
||||
description: |
|
||||
Retrieve detailed information for a specific event by ID.
|
||||
Returns complete event data in simplified format optimized for LLM use.
|
||||
|
||||
Example usage:
|
||||
- "Show me details for event abc123 in my primary calendar"
|
||||
- "Get full information about that meeting I mentioned"
|
||||
- "What are the attendees for event xyz789?"
|
||||
tags:
|
||||
- google-calendar
|
||||
- events
|
||||
- get
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: calendar_id
|
||||
type: string
|
||||
description: "Calendar containing the event"
|
||||
examples: ["primary", "work@company.com", "team-calendar@company.com"]
|
||||
- name: event_id
|
||||
type: string
|
||||
description: "Event identifier"
|
||||
examples: ["abc123def456", "event_id_example", "recurring_event_20240115T090000Z"]
|
||||
return:
|
||||
type: object
|
||||
description: "Complete event details"
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Event identifier"
|
||||
summary:
|
||||
type: string
|
||||
description: "Event title"
|
||||
description:
|
||||
type: string
|
||||
description: "Event description"
|
||||
location:
|
||||
type: string
|
||||
description: "Event location"
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event start time"
|
||||
end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event end time"
|
||||
all_day:
|
||||
type: boolean
|
||||
description: "Whether this is an all-day event"
|
||||
time_zone:
|
||||
type: string
|
||||
description: "Event timezone"
|
||||
attendees:
|
||||
type: array
|
||||
description: "Event attendees"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: "Attendee email"
|
||||
displayName:
|
||||
type: string
|
||||
description: "Attendee name"
|
||||
responseStatus:
|
||||
type: string
|
||||
description: "Response status"
|
||||
status:
|
||||
type: string
|
||||
description: "Event status"
|
||||
htmlLink:
|
||||
type: string
|
||||
description: "Google Calendar web URL"
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Creation timestamp"
|
||||
updated:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Last update timestamp"
|
||||
recurrence:
|
||||
type: array
|
||||
description: "Recurrence rules"
|
||||
items:
|
||||
type: string
|
||||
required: ["id", "summary", "start_time", "end_time", "htmlLink", "status"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
@@ -0,0 +1,83 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: get_freebusy
|
||||
title: Check Calendar Availability
|
||||
description: |
|
||||
Check free/busy status across multiple calendars for a specified time range.
|
||||
Useful for finding meeting slots and checking availability before scheduling.
|
||||
|
||||
Example usage:
|
||||
- "Check my availability tomorrow from 9 AM to 5 PM"
|
||||
- "Find free time slots across my work and personal calendars"
|
||||
- "When am I free for a meeting this week?"
|
||||
- "Check availability for multiple team members' calendars"
|
||||
tags:
|
||||
- google-calendar
|
||||
- freebusy
|
||||
- availability
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: calendar_ids
|
||||
type: array
|
||||
description: "List of calendar IDs to check (use 'primary' for main calendar)"
|
||||
items:
|
||||
type: string
|
||||
description: "Calendar identifier"
|
||||
minItems: 1
|
||||
examples: [["primary"], ["primary", "work@company.com"], ["team@company.com", "resources@company.com"]]
|
||||
- name: time_min
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Start time for availability check (RFC3339 format)"
|
||||
examples: ["2024-01-15T09:00:00Z", "2024-01-15T09:00:00-08:00"]
|
||||
- name: time_max
|
||||
type: string
|
||||
format: date-time
|
||||
description: "End time for availability check (RFC3339 format)"
|
||||
examples: ["2024-01-15T17:00:00Z", "2024-01-15T17:00:00-08:00"]
|
||||
return:
|
||||
type: object
|
||||
description: "Free/busy information for requested calendars"
|
||||
properties:
|
||||
time_min:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Query start time"
|
||||
time_max:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Query end time"
|
||||
calendars:
|
||||
type: array
|
||||
description: "Per-calendar availability information"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
calendar_id:
|
||||
type: string
|
||||
description: "Calendar identifier"
|
||||
busy:
|
||||
type: array
|
||||
description: "Busy time periods"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Busy period start"
|
||||
end:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Busy period end"
|
||||
errors:
|
||||
type: array
|
||||
description: "API errors for this calendar"
|
||||
items:
|
||||
type: object
|
||||
required: ["time_min", "time_max", "calendars"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
@@ -0,0 +1,84 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: list_calendars
|
||||
title: List Google Calendars
|
||||
description: |
|
||||
List all calendars accessible to the authenticated user.
|
||||
Returns calendars with their access permissions, timezones, and display properties.
|
||||
|
||||
Example usage:
|
||||
- "Show me all my calendars"
|
||||
- "List calendars I can write to" (with min_access_role: "writer")
|
||||
- "What calendars do I have access to?"
|
||||
- "Show me my work calendars" (then filter by name)
|
||||
tags:
|
||||
- google-calendar
|
||||
- calendars
|
||||
- list
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: show_hidden
|
||||
type: boolean
|
||||
description: "Include hidden calendars in results"
|
||||
default: false
|
||||
examples: [false, true]
|
||||
- name: show_deleted
|
||||
type: boolean
|
||||
description: "Include deleted calendars in results"
|
||||
default: false
|
||||
examples: [false, true]
|
||||
- name: max_results
|
||||
type: integer
|
||||
description: "Maximum number of calendars to return"
|
||||
default: 100
|
||||
minimum: 1
|
||||
maximum: 250
|
||||
examples: [10, 50, 100]
|
||||
- name: min_access_role
|
||||
type: string
|
||||
description: "Filter by minimum access role"
|
||||
enum: ["freeBusyReader", "owner", "reader", "writer"]
|
||||
default: null
|
||||
examples: ["reader", "writer", "owner"]
|
||||
return:
|
||||
type: array
|
||||
description: "List of accessible calendars"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Calendar identifier (email address or calendar ID)"
|
||||
summary:
|
||||
type: string
|
||||
description: "Calendar name/title"
|
||||
description:
|
||||
type: string
|
||||
description: "Calendar description"
|
||||
timeZone:
|
||||
type: string
|
||||
description: "IANA timezone identifier"
|
||||
accessRole:
|
||||
type: string
|
||||
description: "User's access level"
|
||||
primary:
|
||||
type: boolean
|
||||
description: "Whether this is user's primary calendar"
|
||||
backgroundColor:
|
||||
type: string
|
||||
description: "Background color hex code"
|
||||
foregroundColor:
|
||||
type: string
|
||||
description: "Foreground color hex code"
|
||||
selected:
|
||||
type: boolean
|
||||
description: "Whether calendar is selected in UI"
|
||||
hidden:
|
||||
type: boolean
|
||||
description: "Whether calendar is hidden from list"
|
||||
required: ["id", "summary", "timeZone", "accessRole"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
@@ -0,0 +1,153 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: list_events
|
||||
title: List Calendar Events
|
||||
description: |
|
||||
List events from a specific calendar with optional time filtering and pagination.
|
||||
Returns events in simplified format optimized for LLM consumption.
|
||||
|
||||
Example usage:
|
||||
- "What's on my calendar today?" (with time_min/time_max for today)
|
||||
- "Show me this week's meetings" (with time range for current week)
|
||||
- "List all events in my work calendar" (with specific calendar_id)
|
||||
- "What meetings do I have next month?" (with future time range)
|
||||
tags:
|
||||
- google-calendar
|
||||
- events
|
||||
- list
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: calendar_id
|
||||
type: string
|
||||
description: "Calendar to query ('primary' or specific calendar ID)"
|
||||
default: "primary"
|
||||
examples: ["primary", "work@company.com", "team-calendar@company.com"]
|
||||
- name: time_min
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Lower bound for event start times (RFC3339 format)"
|
||||
default: null
|
||||
examples: ["2024-01-15T00:00:00Z", "2024-01-15T09:00:00-08:00"]
|
||||
- name: time_max
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Upper bound for event start times (RFC3339 format)"
|
||||
default: null
|
||||
examples: ["2024-01-15T23:59:59Z", "2024-01-15T17:00:00-08:00"]
|
||||
- name: max_results
|
||||
type: integer
|
||||
description: "Maximum number of events to return"
|
||||
default: 250
|
||||
minimum: 1
|
||||
maximum: 2500
|
||||
examples: [10, 50, 250]
|
||||
- name: single_events
|
||||
type: boolean
|
||||
description: "Whether to expand recurring events into instances"
|
||||
default: true
|
||||
examples: [true, false]
|
||||
- name: order_by
|
||||
type: string
|
||||
description: "Sort order for events"
|
||||
enum: ["startTime", "updated"]
|
||||
default: "startTime"
|
||||
examples: ["startTime", "updated"]
|
||||
- name: page_token
|
||||
type: string
|
||||
description: "Token for pagination"
|
||||
default: null
|
||||
examples: ["CAESGjBpNDd2Nmp2Zml2cXRwYjBpOXA", "next_page_token_example"]
|
||||
return:
|
||||
type: object
|
||||
description: "Event search results with pagination"
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
description: "Matching events"
|
||||
items:
|
||||
type: object
|
||||
description: "Complete event information with simplified time handling"
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Event identifier"
|
||||
summary:
|
||||
type: string
|
||||
description: "Event title"
|
||||
description:
|
||||
type: string
|
||||
description: "Event description"
|
||||
location:
|
||||
type: string
|
||||
description: "Event location"
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event start time (timezone-aware)"
|
||||
end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event end time (timezone-aware)"
|
||||
all_day:
|
||||
type: boolean
|
||||
description: "Whether this is an all-day event"
|
||||
time_zone:
|
||||
type: string
|
||||
description: "Event timezone (if different from calendar)"
|
||||
attendees:
|
||||
type: array
|
||||
description: "Event attendees"
|
||||
items:
|
||||
type: object
|
||||
creator:
|
||||
type: object
|
||||
description: "Event creator"
|
||||
organizer:
|
||||
type: object
|
||||
description: "Event organizer"
|
||||
status:
|
||||
type: string
|
||||
description: "Event status"
|
||||
htmlLink:
|
||||
type: string
|
||||
description: "Google Calendar web URL for this event"
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event creation timestamp (timezone-aware)"
|
||||
updated:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Last update timestamp (timezone-aware)"
|
||||
recurrence:
|
||||
type: array
|
||||
description: "Recurrence rules in RRULE format"
|
||||
items:
|
||||
type: string
|
||||
reminders:
|
||||
type: object
|
||||
description: "Reminder settings"
|
||||
transparency:
|
||||
type: string
|
||||
description: "Event transparency"
|
||||
visibility:
|
||||
type: string
|
||||
description: "Event visibility"
|
||||
calendar_id:
|
||||
type: string
|
||||
description: "Calendar containing this event"
|
||||
etag:
|
||||
type: string
|
||||
description: "Event ETag for change detection"
|
||||
next_page_token:
|
||||
type: string
|
||||
description: "Token for pagination"
|
||||
total_results:
|
||||
type: integer
|
||||
description: "Total number of matching events"
|
||||
required: ["events"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
@@ -0,0 +1,97 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: search_events
|
||||
title: Search Calendar Events
|
||||
description: |
|
||||
Search for events matching a text query. Searches across event titles, descriptions,
|
||||
locations, and attendee information with optional time filtering.
|
||||
|
||||
Example usage:
|
||||
- "Find all meetings with John" (q: "John")
|
||||
- "Search for events about project Alpha" (q: "project Alpha")
|
||||
- "Find meetings in the conference room" (q: "conference room")
|
||||
- "Show me all standup meetings this month" (q: "standup" with time range)
|
||||
tags:
|
||||
- google-calendar
|
||||
- events
|
||||
- search
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: q
|
||||
type: string
|
||||
description: "Free text search query (searches title, description, location, attendees)"
|
||||
examples: ["John Smith", "project Alpha", "conference room", "standup meeting"]
|
||||
- name: calendar_id
|
||||
type: string
|
||||
description: "Calendar to search ('primary' or specific calendar ID)"
|
||||
default: "primary"
|
||||
examples: ["primary", "work@company.com", "team-calendar@company.com"]
|
||||
- name: time_min
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Earliest event start time to include (RFC3339 format)"
|
||||
default: null
|
||||
examples: ["2024-01-15T00:00:00Z", "2024-01-15T09:00:00-08:00"]
|
||||
- name: time_max
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Latest event start time to include (RFC3339 format)"
|
||||
default: null
|
||||
examples: ["2024-01-15T23:59:59Z", "2024-01-15T17:00:00-08:00"]
|
||||
- name: max_results
|
||||
type: integer
|
||||
description: "Maximum number of events to return"
|
||||
default: 250
|
||||
minimum: 1
|
||||
maximum: 2500
|
||||
examples: [10, 50, 250]
|
||||
- name: page_token
|
||||
type: string
|
||||
description: "Token for pagination"
|
||||
default: null
|
||||
examples: ["CAESGjBpNDd2Nmp2Zml2cXRwYjBpOXA", "next_page_token_example"]
|
||||
return:
|
||||
type: object
|
||||
description: "Search results with matching events"
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
description: "Matching events"
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: "Event identifier"
|
||||
summary:
|
||||
type: string
|
||||
description: "Event title"
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event start time"
|
||||
end_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: "Event end time"
|
||||
location:
|
||||
type: string
|
||||
description: "Event location"
|
||||
description:
|
||||
type: string
|
||||
description: "Event description"
|
||||
htmlLink:
|
||||
type: string
|
||||
description: "Google Calendar web URL"
|
||||
next_page_token:
|
||||
type: string
|
||||
description: "Token for next page of results"
|
||||
total_results:
|
||||
type: integer
|
||||
description: "Number of results in current page"
|
||||
required: ["events"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
@@ -0,0 +1,53 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: whoami
|
||||
title: Current User Information
|
||||
description: |
|
||||
Get the current authenticated user's information (id, email, name) from Google OAuth context.
|
||||
Use this tool to verify authentication status and get user profile data.
|
||||
|
||||
Example usage:
|
||||
- "Who am I logged in as?"
|
||||
- "What's my Google account information?"
|
||||
- "Show me my user profile"
|
||||
tags:
|
||||
- google-calendar
|
||||
- user
|
||||
- auth
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters: []
|
||||
return:
|
||||
type: object
|
||||
description: Current user information from Google OAuth profile
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Google user ID (subject)
|
||||
email:
|
||||
type: string
|
||||
description: User's email address
|
||||
name:
|
||||
type: string
|
||||
description: User's full display name
|
||||
given_name:
|
||||
type: string
|
||||
description: User's first name
|
||||
family_name:
|
||||
type: string
|
||||
description: User's last name
|
||||
picture:
|
||||
type: string
|
||||
description: User's profile picture URL
|
||||
locale:
|
||||
type: string
|
||||
description: User's locale (e.g., 'en-US')
|
||||
verified_email:
|
||||
type: boolean
|
||||
description: Whether email address is verified
|
||||
required: ["id", "email", "name"]
|
||||
language: python
|
||||
source:
|
||||
file: ../python/google_calendar_client.py
|
||||
# NOTE: tests section omitted - OAuth tools cannot be tested via mxcp CLI
|
||||
160
skills/mxcp-expert/assets/project-templates/jira-oauth/README.md
Normal file
160
skills/mxcp-expert/assets/project-templates/jira-oauth/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Connect Jira to MXCP with OAuth
|
||||
|
||||
This example shows how to connect JIRA to MXCP using secure OAuth authentication.
|
||||
|
||||
## What You Get
|
||||
|
||||
Once configured, you can query your Jira data directly from MXCP:
|
||||
|
||||
```sql
|
||||
-- Find all issues assigned to you
|
||||
SELECT jql_query_jira('assignee = currentUser()') AS my_issues;
|
||||
|
||||
-- Get recent bugs in a project
|
||||
SELECT jql_query_jira('project = MYPROJECT AND type = Bug AND created >= -7d') AS recent_bugs;
|
||||
|
||||
-- List all your accessible projects
|
||||
SELECT list_projects_jira() AS projects;
|
||||
|
||||
-- Get user information
|
||||
SELECT get_user_jira('john.doe@company.com') AS user_info;
|
||||
```
|
||||
|
||||
## Quick Setup Guide
|
||||
|
||||
### Step 1: Create Your OAuth App in Atlassian
|
||||
|
||||
1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/)
|
||||
2. Click **Create** → **OAuth 2.0 (3LO)**
|
||||
3. Fill in your app details:
|
||||
- **App name**: `MXCP Jira Integration` (or whatever you prefer)
|
||||
- **Description**: `OAuth integration for MXCP`
|
||||
4. Click **Create**
|
||||
|
||||
### Step 2: Configure OAuth Settings
|
||||
|
||||
After creating your app:
|
||||
|
||||
1. Click on your newly created app
|
||||
2. Go to **Permissions** → **Add** → **Jira API**
|
||||
3. Add these scopes:
|
||||
- `read:me` (to read your own profile information)
|
||||
- `read:jira-work` (to read issues and projects)
|
||||
- `read:jira-user` (to read user information)
|
||||
- `offline_access` (to refresh tokens)
|
||||
|
||||
4. Go to **Authorization** → **OAuth 2.0 (3LO)**
|
||||
5. Add your callback URL based on your deployment:
|
||||
- **For production**: `https://your-domain.com/atlassian/callback`
|
||||
- **For local development**: `http://localhost:8000/atlassian/callback`
|
||||
- **For ngrok testing**: `https://your-ngrok-url.ngrok.io/atlassian/callback`
|
||||
|
||||
6. **Important**: Save your **Client ID** and **Client Secret** - you'll need these next!
|
||||
|
||||
### Step 3: Set Up Environment Variables
|
||||
|
||||
Create a `.env` file or set these environment variables:
|
||||
|
||||
```bash
|
||||
export ATLASSIAN_CLIENT_ID="your-client-id-here"
|
||||
export ATLASSIAN_CLIENT_SECRET="your-client-secret-here"
|
||||
```
|
||||
|
||||
### Step 4: Configure MXCP
|
||||
|
||||
This example includes a ready-to-use `config.yml` file that you can customize with your OAuth credentials. You can either:
|
||||
|
||||
- **Use the included file**: Edit the existing `config.yml` in this directory
|
||||
- **Create your own**: Use the template below
|
||||
|
||||
Configuration template:
|
||||
|
||||
```yaml
|
||||
mxcp: 1.0.0
|
||||
transport:
|
||||
http:
|
||||
port: 8000
|
||||
host: 0.0.0.0
|
||||
# Set base_url to your server's public URL for production
|
||||
base_url: http://localhost:8000
|
||||
|
||||
projects:
|
||||
my-jira-project:
|
||||
profiles:
|
||||
dev:
|
||||
# OAuth Configuration
|
||||
auth:
|
||||
provider: atlassian
|
||||
clients:
|
||||
- client_id: "${ATLASSIAN_CLIENT_ID}"
|
||||
client_secret: "${ATLASSIAN_CLIENT_SECRET}"
|
||||
name: "MXCP Jira Integration"
|
||||
redirect_uris:
|
||||
# For production, use your actual domain (must match base_url above)
|
||||
- "https://your-domain.com/atlassian/callback"
|
||||
# For local development, uncomment the line below:
|
||||
# - "http://localhost:8000/atlassian/callback"
|
||||
scopes:
|
||||
- "mxcp:access"
|
||||
atlassian:
|
||||
client_id: "${ATLASSIAN_CLIENT_ID}"
|
||||
client_secret: "${ATLASSIAN_CLIENT_SECRET}"
|
||||
scope: "read:me read:jira-work read:jira-user offline_access"
|
||||
callback_path: "/atlassian/callback"
|
||||
auth_url: "https://auth.atlassian.com/authorize"
|
||||
token_url: "https://auth.atlassian.com/oauth/token"
|
||||
|
||||
# Plugin Configuration (minimal setup required!)
|
||||
plugin:
|
||||
config:
|
||||
jira_oauth: {} # Named 'jira_oauth' here, but UDFs use 'jira' suffix from mxcp-site.yml
|
||||
```
|
||||
|
||||
### Step 5: Install and Run
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
pip install atlassian-python-api requests
|
||||
```
|
||||
|
||||
2. **Start MXCP**:
|
||||
```bash
|
||||
# From the examples/jira-oauth directory:
|
||||
MXCP_CONFIG=config.yml mxcp serve
|
||||
```
|
||||
|
||||
3. **Authenticate**:
|
||||
- Configure the MXCP server in your MCP client (e.g., Claude Desktop)
|
||||
- When the client connects, you'll be redirected to Atlassian to authorize the app
|
||||
- After authorization, you'll be redirected back to your MCP client
|
||||
- You're now ready to query Jira!
|
||||
|
||||
## Available Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `jql_query_jira(query, start, limit)` | Execute JQL queries | `SELECT jql_query_jira('project = TEST')` |
|
||||
| `list_projects_jira()` | List all your accessible projects | `SELECT list_projects_jira()` |
|
||||
| `get_project_jira(key)` | Get details for a specific project | `SELECT get_project_jira('TEST')` |
|
||||
| `get_user_jira(username)` | Get user information | `SELECT get_user_jira('john@company.com')` |
|
||||
|
||||
## Example Queries
|
||||
|
||||
```sql
|
||||
-- Get your assigned issues
|
||||
SELECT jql_query_jira('assignee = currentUser() AND status != Done', 0, 20) AS my_open_issues;
|
||||
|
||||
-- Find high priority bugs
|
||||
SELECT jql_query_jira('priority = High AND type = Bug', 0, 10) AS high_priority_bugs;
|
||||
|
||||
-- Recent activity in a project
|
||||
SELECT jql_query_jira('project = MYPROJECT AND updated >= -3d') AS recent_activity;
|
||||
|
||||
-- Get project information
|
||||
SELECT
|
||||
list_projects_jira() AS all_projects,
|
||||
get_project_jira('MYPROJECT') AS project_details;
|
||||
|
||||
-- Find issues by reporter
|
||||
SELECT jql_query_jira('reporter = "john.doe@company.com"') AS johns_issues;
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
mxcp: 1
|
||||
transport:
|
||||
http:
|
||||
port: 8000
|
||||
host: 0.0.0.0
|
||||
# Set base_url to your server's public URL for production
|
||||
base_url: http://localhost:8000
|
||||
|
||||
projects:
|
||||
jira-oauth-demo:
|
||||
profiles:
|
||||
dev:
|
||||
# OAuth Authentication Configuration
|
||||
auth:
|
||||
provider: atlassian
|
||||
clients:
|
||||
- client_id: "${ATLASSIAN_CLIENT_ID}"
|
||||
client_secret: "${ATLASSIAN_CLIENT_SECRET}"
|
||||
name: "MXCP Jira OAuth Integration"
|
||||
redirect_uris:
|
||||
# For production, use your actual domain (must match base_url above)
|
||||
- "http://localhost:8000/atlassian/callback"
|
||||
scopes:
|
||||
- "mxcp:access"
|
||||
atlassian:
|
||||
client_id: "${ATLASSIAN_CLIENT_ID}"
|
||||
client_secret: "${ATLASSIAN_CLIENT_SECRET}"
|
||||
scope: "read:me read:jira-work read:jira-user offline_access"
|
||||
callback_path: "/atlassian/callback"
|
||||
auth_url: "https://auth.atlassian.com/authorize"
|
||||
token_url: "https://auth.atlassian.com/oauth/token"
|
||||
|
||||
# Plugin Configuration (minimal configuration - uses OAuth context!)
|
||||
plugin:
|
||||
config:
|
||||
jira_oauth: {} # Named 'jira_oauth' here, but UDFs use 'jira' suffix from mxcp-site.yml
|
||||
@@ -0,0 +1,8 @@
|
||||
mxcp: 1
|
||||
project: jira-oauth-demo
|
||||
profile: dev
|
||||
|
||||
plugin:
|
||||
- name: jira
|
||||
module: mxcp_plugin_jira_oauth
|
||||
config: jira_oauth
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
MXCP Jira OAuth Plugin
|
||||
|
||||
This plugin provides UDFs for querying Atlassian Jira using OAuth authentication.
|
||||
Unlike the API token version, this plugin uses OAuth tokens from authenticated users.
|
||||
"""
|
||||
|
||||
from .plugin import MXCPPlugin
|
||||
|
||||
__all__ = ["MXCPPlugin"]
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Jira OAuth Plugin Implementation
|
||||
|
||||
This module provides UDFs for querying Atlassian Jira using JQL with OAuth 2.0 authentication.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from atlassian import Jira
|
||||
|
||||
from mxcp.plugins import MXCPBasePlugin, udf
|
||||
from mxcp.sdk.auth.context import get_user_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MXCPPlugin(MXCPBasePlugin):
|
||||
"""Jira OAuth plugin that provides JQL query functionality using OAuth 2.0 Bearer tokens."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize the Jira OAuth plugin.
|
||||
|
||||
Args:
|
||||
config: Plugin configuration containing optional settings
|
||||
Optional keys:
|
||||
- oauth_token: Fallback OAuth Bearer token (if not using user context)
|
||||
"""
|
||||
super().__init__(config)
|
||||
self.fallback_oauth_token = config.get("oauth_token", "")
|
||||
self.instance_url: Optional[str] = None
|
||||
|
||||
def _get_oauth_token(self) -> str:
|
||||
"""Get OAuth token from user context or fallback configuration.
|
||||
|
||||
Returns:
|
||||
OAuth Bearer token
|
||||
|
||||
Raises:
|
||||
ValueError: If no OAuth token is available
|
||||
"""
|
||||
# First try to get token from user context (preferred)
|
||||
user_context = get_user_context()
|
||||
if user_context and user_context.external_token:
|
||||
logger.debug("Using OAuth token from user context")
|
||||
return user_context.external_token
|
||||
|
||||
# Fall back to configured token
|
||||
if self.fallback_oauth_token:
|
||||
logger.debug("Using fallback OAuth token from configuration")
|
||||
return self.fallback_oauth_token
|
||||
|
||||
raise ValueError("No OAuth token available from user context or configuration")
|
||||
|
||||
def _get_cloud_id_and_url(self, oauth_token: str) -> tuple[str, str]:
|
||||
"""Get the cloud ID and instance URL for the first accessible Jira instance using the OAuth token.
|
||||
|
||||
Args:
|
||||
oauth_token: OAuth Bearer token
|
||||
|
||||
Returns:
|
||||
Tuple of (cloud_id, instance_url) for the first accessible Jira instance
|
||||
|
||||
Raises:
|
||||
ValueError: If cloud ID and URL cannot be retrieved
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://api.atlassian.com/oauth/token/accessible-resources",
|
||||
headers={"Authorization": f"Bearer {oauth_token}", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
resources = response.json()
|
||||
logger.debug(f"Found {len(resources)} accessible resources")
|
||||
|
||||
# Use the first accessible resource
|
||||
if resources:
|
||||
cloud_id = resources[0].get("id")
|
||||
instance_url = resources[0].get("url")
|
||||
logger.info(f"Using cloud ID: {cloud_id} for instance: {instance_url}")
|
||||
return cloud_id, instance_url
|
||||
|
||||
raise ValueError(f"No accessible resources found for OAuth token")
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to get cloud ID and URL: {e}")
|
||||
raise ValueError(f"Failed to retrieve cloud ID and URL: {e}")
|
||||
|
||||
def _create_jira_client(self) -> Jira:
|
||||
"""Create a Jira client with OAuth authentication using the correct API gateway URL.
|
||||
|
||||
Returns:
|
||||
Configured Jira client instance
|
||||
"""
|
||||
oauth_token = self._get_oauth_token()
|
||||
|
||||
# Get the cloud ID and instance URL for the first accessible Jira instance
|
||||
cloud_id, instance_url = self._get_cloud_id_and_url(oauth_token)
|
||||
|
||||
# Store the instance URL for constructing web UI URLs
|
||||
self.instance_url = instance_url
|
||||
|
||||
# Construct the API gateway URL for OAuth requests
|
||||
api_gateway_url = f"https://api.atlassian.com/ex/jira/{cloud_id}"
|
||||
logger.info("API Gateway URL: %s", api_gateway_url)
|
||||
|
||||
# Create a requests session with OAuth Bearer token
|
||||
session = requests.Session()
|
||||
session.headers["Authorization"] = f"Bearer {oauth_token}"
|
||||
|
||||
# Create and return Jira client with the OAuth session and API gateway URL
|
||||
# Explicitly set cloud=True since we're using Jira Cloud with OAuth
|
||||
return Jira(url=api_gateway_url, session=session, cloud=True)
|
||||
|
||||
@udf
|
||||
def jql_query(self, query: str, start: Optional[int] = 0, limit: Optional[int] = None) -> str:
|
||||
"""Execute a JQL query against Jira using OAuth authentication.
|
||||
|
||||
Args:
|
||||
query: The JQL query string
|
||||
start: Starting index for pagination (default: 0)
|
||||
limit: Maximum number of results to return (default: None, meaning no limit)
|
||||
|
||||
Returns:
|
||||
JSON string containing Jira issues matching the query
|
||||
"""
|
||||
logger.info(
|
||||
"Executing JQL query with OAuth: %s with start=%s, limit=%s", query, start, limit
|
||||
)
|
||||
|
||||
# Create Jira client with current user's OAuth token
|
||||
jira = self._create_jira_client()
|
||||
|
||||
raw = jira.jql(
|
||||
jql=query,
|
||||
start=start,
|
||||
limit=limit,
|
||||
fields=(
|
||||
"key,summary,status,resolution,resolutiondate,"
|
||||
"assignee,reporter,issuetype,priority,"
|
||||
"created,updated,labels,fixVersions,parent"
|
||||
),
|
||||
)
|
||||
|
||||
def _name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Return obj['name'] if present, else None."""
|
||||
return obj.get("name") if obj else None
|
||||
|
||||
def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return obj.get("key") if obj else None
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for issue in raw.get("issues", []):
|
||||
f = issue["fields"]
|
||||
|
||||
cleaned.append(
|
||||
{
|
||||
"key": issue["key"],
|
||||
"summary": f.get("summary"),
|
||||
"status": _name(f.get("status")),
|
||||
"resolution": _name(f.get("resolution")),
|
||||
"resolution_date": f.get("resolutiondate"),
|
||||
"assignee": _name(f.get("assignee")),
|
||||
"reporter": _name(f.get("reporter")),
|
||||
"type": _name(f.get("issuetype")),
|
||||
"priority": _name(f.get("priority")),
|
||||
"created": f.get("created"),
|
||||
"updated": f.get("updated"),
|
||||
"labels": f.get("labels") or [],
|
||||
"fix_versions": [_name(v) for v in f.get("fixVersions", [])],
|
||||
"parent": _key(f.get("parent")),
|
||||
"url": f"{self.instance_url}/browse/{issue['key']}", # web UI URL
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(cleaned)
|
||||
|
||||
@udf
|
||||
def get_user(self, username: str) -> str:
|
||||
"""Get details for a specific user by username using OAuth.
|
||||
|
||||
Args:
|
||||
username: The username to search for
|
||||
|
||||
Returns:
|
||||
JSON string containing the user details
|
||||
"""
|
||||
logger.info("Getting user details with OAuth for username: %s", username)
|
||||
|
||||
# Create Jira client with current user's OAuth token
|
||||
jira = self._create_jira_client()
|
||||
|
||||
return json.dumps(jira.user_find_by_user_string(query=username))
|
||||
|
||||
@udf
|
||||
def list_projects(self) -> str:
|
||||
"""List all accessible Jira projects using OAuth authentication.
|
||||
|
||||
Returns:
|
||||
JSON string containing an array of accessible Jira projects
|
||||
"""
|
||||
logger.info("Listing all projects with OAuth")
|
||||
|
||||
# Create Jira client with current user's OAuth token
|
||||
jira = self._create_jira_client()
|
||||
|
||||
raw_projects: List[Dict[str, Any]] = jira.projects()
|
||||
|
||||
def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return obj.get("displayName") or obj.get("name") if obj else None
|
||||
|
||||
concise: List[Dict[str, Any]] = []
|
||||
for p in raw_projects:
|
||||
concise.append(
|
||||
{
|
||||
"key": p.get("key"),
|
||||
"name": p.get("name"),
|
||||
"type": p.get("projectTypeKey"), # e.g. software, business
|
||||
"lead": safe_name(p.get("lead")),
|
||||
"url": f"{self.instance_url}/projects/{p.get('key')}", # web UI URL
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(concise)
|
||||
|
||||
@udf
|
||||
def get_project(self, project_key: str) -> str:
|
||||
"""Get details for a specific project by its key using OAuth.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
|
||||
Returns:
|
||||
JSON string containing the project details
|
||||
"""
|
||||
logger.info("Getting project details with OAuth for key: %s", project_key)
|
||||
|
||||
# Create Jira client with current user's OAuth token
|
||||
jira = self._create_jira_client()
|
||||
|
||||
info = jira.project(project_key)
|
||||
# remove the self key if it exists
|
||||
if "self" in info:
|
||||
info.pop("self")
|
||||
# Add web UI URL
|
||||
info["url"] = f"{self.instance_url}/projects/{project_key}"
|
||||
return json.dumps(info)
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Get the username of the currently authenticated user
|
||||
SELECT get_username() as authenticated_user;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Get details for a specific Jira project using OAuth authentication
|
||||
SELECT get_project_jira($project_key) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Get details for a specific Jira user using OAuth authentication
|
||||
SELECT get_user_jira($username) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Example JQL query endpoint using OAuth authentication
|
||||
SELECT jql_query_jira($query, $start, $limit) as result;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- List all projects in Jira using OAuth authentication
|
||||
SELECT list_projects_jira() as result;
|
||||
@@ -0,0 +1,25 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_current_user
|
||||
description: |
|
||||
Get the username of the currently authenticated user in MXCP.
|
||||
This tool returns the username of the person who is authenticated via OAuth with Jira.
|
||||
It's useful for understanding whose credentials are being used for Jira API calls,
|
||||
and can help verify that the OAuth authentication flow completed successfully.
|
||||
The username typically corresponds to the Atlassian account email address.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Current Authenticated User
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: false
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
The username (typically email address) of the currently authenticated user.
|
||||
Returns NULL if no user is authenticated.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/get_current_user.sql"
|
||||
@@ -0,0 +1,32 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project
|
||||
description: |
|
||||
Get details for a specific project in your Jira instance by its project key using OAuth authentication.
|
||||
Returns a JSON string containing the project's details.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project Details (OAuth)
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key to search for. This is the short identifier for the project (e.g., 'TEST' for project TEST).
|
||||
Project keys are typically uppercase and contain only letters and numbers.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing the project's details.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/get_project.sql"
|
||||
@@ -0,0 +1,30 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_user
|
||||
description: |
|
||||
Get details for a specific user in your Jira instance by their username using OAuth authentication.
|
||||
Returns a JSON string containing the user's details.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get User Details (OAuth)
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: username
|
||||
type: string
|
||||
description: |
|
||||
The username to search for. This is typically the user's email address or username in Jira.
|
||||
examples: [
|
||||
"john.doe@example.com",
|
||||
"jane.smith"
|
||||
]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing the user's details.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/get_user.sql"
|
||||
@@ -0,0 +1,50 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: jql
|
||||
description: |
|
||||
Execute a JQL (Jira Query Language) query to search for issues in your Jira instance using OAuth authentication.
|
||||
Returns a JSON string containing the matching issues with their details.
|
||||
Use the start and limit parameters to paginate through large result sets.
|
||||
type: tool
|
||||
annotations:
|
||||
title: JQL Query (OAuth)
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: |
|
||||
The JQL query string to execute. Examples:
|
||||
- "project = TEST" to find all issues in the TEST project
|
||||
- "assignee = currentUser()" to find issues assigned to you
|
||||
- "status = 'In Progress'" to find issues in progress
|
||||
examples: [
|
||||
"project = TEST",
|
||||
"status = 'In Progress'",
|
||||
"project = TEST AND status = 'Done'",
|
||||
"created >= -30d ORDER BY created DESC"
|
||||
]
|
||||
- name: start
|
||||
type: integer
|
||||
description: |
|
||||
The index of the first result to return (0-based).
|
||||
Use this for pagination: start=0 for first page, start=50 for second page, etc.
|
||||
Defaults to 0 if not specified.
|
||||
examples: [0, 50, 100]
|
||||
- name: limit
|
||||
type: integer
|
||||
description: |
|
||||
Maximum number of results to return.
|
||||
If not specified, returns all matching results.
|
||||
Recommended to use with start parameter for pagination.
|
||||
examples: [50, 100, 200]
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing an array of Jira issues.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/jql.sql"
|
||||
@@ -0,0 +1,21 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: list_projects
|
||||
description: |
|
||||
List all projects in your Jira instance using OAuth authentication.
|
||||
Returns a JSON string containing an array of projects with their details.
|
||||
type: tool
|
||||
annotations:
|
||||
title: List Projects (OAuth)
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
return:
|
||||
type: string
|
||||
description: |
|
||||
A JSON string containing an array of Jira projects.
|
||||
language: "sql"
|
||||
source:
|
||||
file: "../sql/list_projects.sql"
|
||||
145
skills/mxcp-expert/assets/project-templates/jira/README.md
Normal file
145
skills/mxcp-expert/assets/project-templates/jira/README.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# MXCP Jira Python Endpoints Example
|
||||
|
||||
This example demonstrates how to use MXCP with Jira data using Python endpoints. This approach uses Python functions directly as MCP tools.
|
||||
|
||||
## Overview
|
||||
|
||||
This example provides Python MCP endpoints that allow you to:
|
||||
- Execute JQL queries to search issues
|
||||
- Get detailed information for specific issues
|
||||
- Get user information
|
||||
- List projects and their details
|
||||
- Get project metadata
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
This example uses Python functions that are exposed as MCP tools:
|
||||
- Python functions handle the Jira API interactions
|
||||
- Tool definitions map to these Python functions
|
||||
- Results are returned as JSON data
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Creating an Atlassian API Token
|
||||
|
||||
**Important:** This plugin currently only supports API tokens **without scopes**. While Atlassian has introduced scoped API tokens, there are known compatibility issues when using scoped tokens with basic authentication that this plugin relies on.
|
||||
|
||||
To create an API token without scopes:
|
||||
|
||||
1. **Log in to your Atlassian account** at [https://id.atlassian.com/manage-profile/security/api-tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
|
||||
2. **Verify your identity** (if prompted):
|
||||
- Atlassian may ask you to verify your identity before creating API tokens
|
||||
- Check your email for a one-time passcode and enter it when prompted
|
||||
|
||||
3. **Create the API token**:
|
||||
- Click **"Create API token"** (not "Create API token with scopes")
|
||||
- Enter a descriptive name for your token (e.g., "MXCP Jira Python Integration")
|
||||
- Select an expiration date (tokens can last from 1 day to 1 year)
|
||||
- Click **"Create"**
|
||||
|
||||
4. **Copy and save your token**:
|
||||
- Click **"Copy to clipboard"** to copy the token
|
||||
- **Important:** Save this token securely (like in a password manager) as you won't be able to view it again
|
||||
- This token will be used as your "password" in the configuration below
|
||||
|
||||
### 2. User Configuration
|
||||
|
||||
Add the following to your MXCP user config (`~/.mxcp/config.yml`):
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
|
||||
projects:
|
||||
jira-demo:
|
||||
profiles:
|
||||
default:
|
||||
secrets:
|
||||
- name: "jira"
|
||||
type: "python"
|
||||
parameters:
|
||||
url: "https://your-domain.atlassian.net"
|
||||
username: "your-email@example.com"
|
||||
password: "your-api-token" # Use the API token you created above
|
||||
```
|
||||
|
||||
### 3. Site Configuration
|
||||
|
||||
Create an `mxcp-site.yml` file:
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
project: jira-demo
|
||||
profile: default
|
||||
secrets:
|
||||
- jira
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### JQL Query
|
||||
Execute JQL queries:
|
||||
```bash
|
||||
mxcp run tool jql_query --param query="project = TEST" --param limit=10
|
||||
```
|
||||
|
||||
### Get Issue
|
||||
Get detailed information for a specific issue by its key:
|
||||
```bash
|
||||
mxcp run tool get_issue --param issue_key="RD-123"
|
||||
```
|
||||
|
||||
### Get User
|
||||
Get a specific user by their account ID:
|
||||
```bash
|
||||
mxcp run tool get_user --param account_id="557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
```
|
||||
|
||||
### Search Users
|
||||
Search for users by name, email, or other criteria:
|
||||
```bash
|
||||
mxcp run tool search_user --param query="john.doe@example.com"
|
||||
```
|
||||
|
||||
### List Projects
|
||||
List all projects:
|
||||
```bash
|
||||
mxcp run tool list_projects
|
||||
```
|
||||
|
||||
### Get Project
|
||||
Get project details:
|
||||
```bash
|
||||
mxcp run tool get_project --param project_key="TEST"
|
||||
```
|
||||
|
||||
### Get Project Roles
|
||||
Get all roles available in a project:
|
||||
```bash
|
||||
mxcp run tool get_project_roles --param project_key="TEST"
|
||||
```
|
||||
|
||||
### Get Project Role Users
|
||||
Get users and groups for a specific role in a project:
|
||||
```bash
|
||||
mxcp run tool get_project_role_users --param project_key="TEST" --param role_name="Developers"
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
jira-python/
|
||||
├── mxcp-site.yml # Site configuration
|
||||
├── python/ # Python implementations
|
||||
│ └── jira_endpoints.py # All JIRA endpoint functions
|
||||
├── tools/ # Tool definitions
|
||||
│ ├── jql_query.yml
|
||||
│ ├── get_issue.yml
|
||||
│ ├── get_user.yml
|
||||
│ ├── search_user.yml
|
||||
│ ├── list_projects.yml
|
||||
│ ├── get_project.yml
|
||||
│ ├── get_project_roles.yml
|
||||
│ └── get_project_role_users.yml
|
||||
└── README.md
|
||||
```
|
||||
17
skills/mxcp-expert/assets/project-templates/jira/config.yml
Normal file
17
skills/mxcp-expert/assets/project-templates/jira/config.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
mxcp: 1
|
||||
|
||||
# Sample configuration file for JIRA Python endpoints example
|
||||
# Copy this to ~/.mxcp/config.yml and update with your JIRA details
|
||||
|
||||
projects:
|
||||
jira-demo:
|
||||
profiles:
|
||||
default:
|
||||
secrets:
|
||||
- name: "jira"
|
||||
type: "python"
|
||||
parameters:
|
||||
url: "${JIRA_URL}"
|
||||
username: "${JIRA_USERNAME}"
|
||||
password: "${JIRA_API_TOKEN}"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mxcp: 1
|
||||
project: jira-demo
|
||||
profile: default
|
||||
secrets:
|
||||
- jira
|
||||
@@ -0,0 +1,569 @@
|
||||
"""
|
||||
JIRA Python Endpoints
|
||||
|
||||
This module provides direct Python MCP endpoints for querying Atlassian JIRA.
|
||||
This is a simpler alternative to the plugin-based approach.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
import logging
|
||||
from atlassian import Jira
|
||||
from mxcp.runtime import config, on_init, on_shutdown
|
||||
import threading
|
||||
import functools
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global JIRA client for reuse across all function calls
|
||||
jira_client: Optional[Jira] = None
|
||||
# Thread lock to protect client initialization
|
||||
_client_lock = threading.Lock()
|
||||
|
||||
|
||||
@on_init
|
||||
def setup_jira_client() -> None:
|
||||
"""Initialize JIRA client when server starts.
|
||||
|
||||
Thread-safe: multiple threads can safely call this simultaneously.
|
||||
"""
|
||||
global jira_client
|
||||
|
||||
with _client_lock:
|
||||
logger.info("Initializing JIRA client...")
|
||||
|
||||
jira_config = config.get_secret("jira")
|
||||
if not jira_config:
|
||||
raise ValueError(
|
||||
"JIRA configuration not found. Please configure JIRA secrets in your user config."
|
||||
)
|
||||
|
||||
required_keys = ["url", "username", "password"]
|
||||
missing_keys = [key for key in required_keys if not jira_config.get(key)]
|
||||
if missing_keys:
|
||||
raise ValueError(f"Missing JIRA configuration keys: {', '.join(missing_keys)}")
|
||||
|
||||
jira_client = Jira(
|
||||
url=jira_config["url"],
|
||||
username=jira_config["username"],
|
||||
password=jira_config["password"],
|
||||
cloud=True,
|
||||
)
|
||||
|
||||
logger.info("JIRA client initialized successfully")
|
||||
|
||||
|
||||
@on_shutdown
|
||||
def cleanup_jira_client() -> None:
|
||||
"""Clean up JIRA client when server stops."""
|
||||
global jira_client
|
||||
if jira_client:
|
||||
# JIRA client doesn't need explicit cleanup, but we'll clear the reference
|
||||
jira_client = None
|
||||
logger.info("JIRA client cleaned up")
|
||||
|
||||
|
||||
def retry_on_session_expiration(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator that automatically retries functions on JIRA session expiration.
|
||||
|
||||
This only retries on HTTP 401 Unauthorized errors, not other authentication failures.
|
||||
Retries up to 2 times on session expiration (3 total attempts).
|
||||
Thread-safe: setup_jira_client() handles concurrent access internally.
|
||||
|
||||
Usage:
|
||||
@retry_on_session_expiration
|
||||
def my_jira_function():
|
||||
# Function that might fail due to session expiration
|
||||
pass
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
max_retries = 2 # Hardcoded: 2 retries = 3 total attempts
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
# Check if this is a 401 Unauthorized error (session expired)
|
||||
if _is_session_expired(e):
|
||||
if attempt < max_retries:
|
||||
logger.warning(
|
||||
f"Session expired on attempt {attempt + 1} in {func.__name__}: {e}"
|
||||
)
|
||||
logger.info(
|
||||
f"Retrying after re-initializing client (attempt {attempt + 2}/{max_retries + 1})"
|
||||
)
|
||||
|
||||
try:
|
||||
setup_jira_client() # Thread-safe internally
|
||||
time.sleep(0.1) # Small delay to avoid immediate retry
|
||||
except Exception as setup_error:
|
||||
logger.error(f"Failed to re-initialize JIRA client: {setup_error}")
|
||||
raise setup_error # Raise the setup error, not the original session error
|
||||
else:
|
||||
# Last attempt failed, re-raise the session expiration error
|
||||
raise e
|
||||
else:
|
||||
# Not a session expiration error, re-raise immediately
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _is_session_expired(exception: Exception) -> bool:
|
||||
"""Check if the exception indicates a JIRA session has expired."""
|
||||
error_msg = str(exception).lower()
|
||||
|
||||
# Check for HTTP 401 Unauthorized
|
||||
if "401" in error_msg or "unauthorized" in error_msg:
|
||||
return True
|
||||
|
||||
# Check for common session expiration messages
|
||||
if any(
|
||||
phrase in error_msg
|
||||
for phrase in [
|
||||
"session expired",
|
||||
"session invalid",
|
||||
"authentication failed",
|
||||
"invalid session",
|
||||
"session timeout",
|
||||
]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _get_jira_client() -> Jira:
|
||||
"""Get the global JIRA client."""
|
||||
if jira_client is None:
|
||||
raise RuntimeError("JIRA client not initialized. Make sure the server is started properly.")
|
||||
return jira_client
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def jql_query(
|
||||
query: str, start: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Execute a JQL query against Jira.
|
||||
|
||||
Args:
|
||||
query: The JQL query string
|
||||
start: Starting index for pagination (default: None, which becomes 0)
|
||||
limit: Maximum number of results to return (default: None, meaning no limit)
|
||||
|
||||
Returns:
|
||||
List of Jira issues matching the query
|
||||
"""
|
||||
logger.info("Executing JQL query: %s with start=%s, limit=%s", query, start, limit)
|
||||
|
||||
jira = _get_jira_client()
|
||||
|
||||
raw = jira.jql(
|
||||
jql=query,
|
||||
start=start if start is not None else 0,
|
||||
limit=limit,
|
||||
fields=(
|
||||
"key,summary,status,resolution,resolutiondate,"
|
||||
"assignee,reporter,issuetype,priority,"
|
||||
"created,updated,labels,fixVersions,parent"
|
||||
),
|
||||
)
|
||||
|
||||
if not raw:
|
||||
raise ValueError("JIRA JQL query returned empty result")
|
||||
|
||||
def _name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""Return obj['name'] if present, else None."""
|
||||
return obj.get("name") if obj else None
|
||||
|
||||
def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return obj.get("key") if obj else None
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
jira_url = jira.url
|
||||
|
||||
for issue in raw.get("issues", []):
|
||||
f = issue["fields"]
|
||||
|
||||
cleaned.append(
|
||||
{
|
||||
"key": issue["key"],
|
||||
"summary": f.get("summary"),
|
||||
"status": _name(f.get("status")),
|
||||
"resolution": _name(f.get("resolution")),
|
||||
"resolution_date": f.get("resolutiondate"),
|
||||
"assignee": _name(f.get("assignee")),
|
||||
"reporter": _name(f.get("reporter")),
|
||||
"type": _name(f.get("issuetype")),
|
||||
"priority": _name(f.get("priority")),
|
||||
"created": f.get("created"),
|
||||
"updated": f.get("updated"),
|
||||
"labels": f.get("labels") or [],
|
||||
"fix_versions": [_name(v) for v in f.get("fixVersions", [])],
|
||||
"parent": _key(f.get("parent")),
|
||||
"url": f"{jira_url}/browse/{issue['key']}", # web UI URL
|
||||
}
|
||||
)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_issue(issue_key: str) -> Dict[str, Any]:
|
||||
"""Get detailed information for a specific JIRA issue by its key.
|
||||
|
||||
Args:
|
||||
issue_key: The issue key (e.g., 'RD-123', 'TEST-456')
|
||||
|
||||
Returns:
|
||||
Dictionary containing comprehensive issue information
|
||||
|
||||
Raises:
|
||||
ValueError: If issue is not found or access is denied
|
||||
"""
|
||||
logger.info("Getting issue details for key: %s", issue_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
# Get issue by key - this method handles the REST API call
|
||||
issue = jira.issue(issue_key)
|
||||
|
||||
# Extract and clean up the most important fields for easier consumption
|
||||
fields = issue.get("fields", {})
|
||||
jira_url = jira.url
|
||||
|
||||
def _safe_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
"""Safely get a value from a dict/object that might be None."""
|
||||
if obj is None:
|
||||
return default
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
cleaned_issue = {
|
||||
"key": issue.get("key"),
|
||||
"id": issue.get("id"),
|
||||
"summary": fields.get("summary"),
|
||||
"description": fields.get("description"),
|
||||
"status": _safe_get(fields.get("status"), "name"),
|
||||
"assignee": _safe_get(fields.get("assignee"), "displayName"),
|
||||
"assignee_account_id": _safe_get(fields.get("assignee"), "accountId"),
|
||||
"reporter": _safe_get(fields.get("reporter"), "displayName"),
|
||||
"reporter_account_id": _safe_get(fields.get("reporter"), "accountId"),
|
||||
"issue_type": _safe_get(fields.get("issuetype"), "name"),
|
||||
"priority": _safe_get(fields.get("priority"), "name"),
|
||||
"resolution": _safe_get(fields.get("resolution"), "name"),
|
||||
"resolution_date": fields.get("resolutiondate"),
|
||||
"created": fields.get("created"),
|
||||
"updated": fields.get("updated"),
|
||||
"due_date": fields.get("duedate"),
|
||||
"labels": fields.get("labels", []) or [],
|
||||
"components": (
|
||||
[comp.get("name") for comp in fields.get("components", []) if comp and comp.get("name")]
|
||||
if fields.get("components")
|
||||
else []
|
||||
),
|
||||
"fix_versions": (
|
||||
[ver.get("name") for ver in fields.get("fixVersions", []) if ver and ver.get("name")]
|
||||
if fields.get("fixVersions")
|
||||
else []
|
||||
),
|
||||
"project": {
|
||||
"key": _safe_get(fields.get("project"), "key"),
|
||||
"name": _safe_get(fields.get("project"), "name"),
|
||||
},
|
||||
"parent": _safe_get(fields.get("parent"), "key"),
|
||||
"url": f"{jira_url}/browse/{issue.get('key')}",
|
||||
}
|
||||
|
||||
return cleaned_issue
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_user(account_id: str) -> Dict[str, Any]:
|
||||
"""Get a specific user by their unique account ID.
|
||||
|
||||
Args:
|
||||
account_id: The unique Atlassian account ID for the user.
|
||||
Example: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
|
||||
Returns:
|
||||
Dictionary containing filtered user details
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not found or account ID is invalid
|
||||
"""
|
||||
logger.info("Getting user details for account ID: %s", account_id)
|
||||
jira = _get_jira_client()
|
||||
|
||||
# Get user by account ID - pass as account_id parameter for Jira Cloud
|
||||
user = jira.user(account_id=account_id)
|
||||
|
||||
# Return only the requested fields
|
||||
return {
|
||||
"accountId": user.get("accountId"),
|
||||
"displayName": user.get("displayName"),
|
||||
"emailAddress": user.get("emailAddress"),
|
||||
"active": user.get("active"),
|
||||
"timeZone": user.get("timeZone"),
|
||||
}
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def search_user(query: str) -> List[Dict[str, Any]]:
|
||||
"""Search for users by query string (username, email, or display name).
|
||||
|
||||
Args:
|
||||
query: Search term - can be username, email, display name, or partial matches.
|
||||
Examples: "ben@raw-labs.com", "Benjamin Gaidioz", "ben", "benjamin", "gaidioz"
|
||||
|
||||
Returns:
|
||||
List of matching users with filtered fields. Empty list if no matches found.
|
||||
"""
|
||||
logger.info("Searching for users with query: %s", query)
|
||||
jira = _get_jira_client()
|
||||
|
||||
# user_find_by_user_string returns a list of users matching the query
|
||||
users = jira.user_find_by_user_string(query=query)
|
||||
|
||||
if not users:
|
||||
return []
|
||||
|
||||
# Filter users to only include relevant fields
|
||||
filtered_users = []
|
||||
for user in users:
|
||||
filtered_users.append(
|
||||
{
|
||||
"accountId": user.get("accountId"),
|
||||
"displayName": user.get("displayName"),
|
||||
"emailAddress": user.get("emailAddress"),
|
||||
"active": user.get("active"),
|
||||
"timeZone": user.get("timeZone"),
|
||||
}
|
||||
)
|
||||
|
||||
return filtered_users
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def list_projects() -> List[Dict[str, Any]]:
|
||||
"""Return a concise list of Jira projects.
|
||||
|
||||
Returns:
|
||||
List of dictionaries containing project information
|
||||
"""
|
||||
logger.info("Listing all projects")
|
||||
|
||||
jira = _get_jira_client()
|
||||
raw_projects: List[Dict[str, Any]] = jira.projects(expand="lead")
|
||||
|
||||
def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return obj.get("displayName") or obj.get("name") if obj else None
|
||||
|
||||
concise: List[Dict[str, Any]] = []
|
||||
jira_url = jira.url
|
||||
|
||||
for p in raw_projects:
|
||||
concise.append(
|
||||
{
|
||||
"key": p.get("key"),
|
||||
"name": p.get("name"),
|
||||
"type": p.get("projectTypeKey"), # e.g. software, business
|
||||
"lead": safe_name(p.get("lead")),
|
||||
"url": f"{jira_url}/projects/{p.get('key')}", # web UI URL
|
||||
}
|
||||
)
|
||||
|
||||
return concise
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_project(project_key: str) -> Dict[str, Any]:
|
||||
"""Get details for a specific project by its key.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
|
||||
Returns:
|
||||
Dictionary containing the project details
|
||||
|
||||
Raises:
|
||||
ValueError: If project is not found or access is denied
|
||||
"""
|
||||
logger.info("Getting project details for key: %s", project_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
try:
|
||||
info = jira.project(project_key)
|
||||
except Exception as e:
|
||||
# Handle various possible errors from the JIRA API
|
||||
error_msg = str(e).lower()
|
||||
if "404" in error_msg or "not found" in error_msg:
|
||||
raise ValueError(f"Project '{project_key}' not found in JIRA")
|
||||
elif "403" in error_msg or "forbidden" in error_msg:
|
||||
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
|
||||
else:
|
||||
# Re-raise other errors with context
|
||||
raise ValueError(f"Error retrieving project '{project_key}': {e}") from e
|
||||
|
||||
# Filter to essential fields only to avoid response size issues
|
||||
cleaned_info = {
|
||||
"key": info.get("key"),
|
||||
"name": info.get("name"),
|
||||
"description": info.get("description"),
|
||||
"projectTypeKey": info.get("projectTypeKey"),
|
||||
"simplified": info.get("simplified"),
|
||||
"style": info.get("style"),
|
||||
"isPrivate": info.get("isPrivate"),
|
||||
"archived": info.get("archived"),
|
||||
}
|
||||
|
||||
# Add lead info if present
|
||||
if "lead" in info and info["lead"]:
|
||||
cleaned_info["lead"] = {
|
||||
"displayName": info["lead"].get("displayName"),
|
||||
"emailAddress": info["lead"].get("emailAddress"),
|
||||
"accountId": info["lead"].get("accountId"),
|
||||
"active": info["lead"].get("active"),
|
||||
}
|
||||
|
||||
cleaned_info["url"] = f"{jira.url}/projects/{project_key}"
|
||||
|
||||
return cleaned_info
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_project_roles(project_key: str) -> List[Dict[str, Any]]:
|
||||
"""Get all roles available in a project.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
|
||||
Returns:
|
||||
List of roles available in the project
|
||||
|
||||
Raises:
|
||||
ValueError: If project is not found or access is denied
|
||||
"""
|
||||
logger.info("Getting project roles for key: %s", project_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
try:
|
||||
# Get all project roles using the correct method
|
||||
project_roles = jira.get_project_roles(project_key)
|
||||
|
||||
result = []
|
||||
for role_name, role_url in project_roles.items():
|
||||
# Extract role ID from URL (e.g., "https://domain.atlassian.net/rest/api/3/project/10000/role/10002")
|
||||
role_id = role_url.split("/")[-1]
|
||||
|
||||
result.append({"name": role_name, "id": role_id})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Handle various possible errors from the JIRA API
|
||||
error_msg = str(e).lower()
|
||||
if "404" in error_msg or "not found" in error_msg:
|
||||
raise ValueError(f"Project '{project_key}' not found in JIRA")
|
||||
elif "403" in error_msg or "forbidden" in error_msg:
|
||||
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
|
||||
else:
|
||||
# Re-raise other errors with context
|
||||
raise ValueError(f"Error retrieving project roles for '{project_key}': {e}") from e
|
||||
|
||||
|
||||
@retry_on_session_expiration
|
||||
def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]:
|
||||
"""Get users and groups for a specific role in a project.
|
||||
|
||||
Args:
|
||||
project_key: The project key (e.g., 'TEST' for project TEST)
|
||||
role_name: The name of the role to get users for
|
||||
|
||||
Returns:
|
||||
Dictionary containing users and groups for the specified role
|
||||
|
||||
Raises:
|
||||
ValueError: If project or role is not found, or access is denied
|
||||
"""
|
||||
logger.info("Getting users for role '%s' in project '%s'", role_name, project_key)
|
||||
jira = _get_jira_client()
|
||||
|
||||
try:
|
||||
# First get all project roles to find the role ID
|
||||
project_roles = jira.get_project_roles(project_key)
|
||||
|
||||
if role_name not in project_roles:
|
||||
available_roles = list(project_roles.keys())
|
||||
raise ValueError(
|
||||
f"Role '{role_name}' not found in project '{project_key}'. Available roles: {available_roles}"
|
||||
)
|
||||
|
||||
# Extract role ID from URL
|
||||
role_url = project_roles[role_name]
|
||||
role_id = role_url.split("/")[-1]
|
||||
|
||||
# Get role details including actors (users and groups)
|
||||
role_details = jira.get_project_actors_for_role_project(project_key, role_id)
|
||||
|
||||
result = {
|
||||
"project_key": project_key,
|
||||
"role_name": role_name,
|
||||
"role_id": role_id,
|
||||
"users": [],
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
# Process actors (role_details is a list of actors)
|
||||
if isinstance(role_details, list):
|
||||
for actor in role_details:
|
||||
if isinstance(actor, dict):
|
||||
actor_type = actor.get("type", "")
|
||||
if actor_type == "atlassian-user-role-actor":
|
||||
# Individual user
|
||||
user_info = {
|
||||
"accountId": actor.get("actorUser", {}).get("accountId"),
|
||||
"displayName": actor.get("displayName"),
|
||||
}
|
||||
result["users"].append(user_info)
|
||||
elif actor_type == "atlassian-group-role-actor":
|
||||
# Group
|
||||
group_info = {
|
||||
"name": actor.get("displayName"),
|
||||
"groupId": actor.get("actorGroup", {}).get("groupId"),
|
||||
}
|
||||
result["groups"].append(group_info)
|
||||
else:
|
||||
# Handle other actor types or simple user entries
|
||||
display_name = actor.get("displayName") or actor.get("name")
|
||||
if display_name:
|
||||
user_info = {
|
||||
"accountId": actor.get("accountId"),
|
||||
"displayName": display_name,
|
||||
}
|
||||
result["users"].append(user_info)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError:
|
||||
# Re-raise ValueError as-is (these are our custom error messages)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Handle various possible errors from the JIRA API
|
||||
error_msg = str(e).lower()
|
||||
|
||||
# Don't handle 401 errors here - let the retry decorator handle them
|
||||
if "401" in error_msg or "unauthorized" in error_msg:
|
||||
raise e # Let the retry decorator catch this
|
||||
elif "404" in error_msg or "not found" in error_msg:
|
||||
raise ValueError(f"Project '{project_key}' not found in JIRA")
|
||||
elif "403" in error_msg or "forbidden" in error_msg:
|
||||
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
|
||||
else:
|
||||
# Re-raise other errors with context
|
||||
raise ValueError(
|
||||
f"Error retrieving users for role '{role_name}' in project '{project_key}': {e}"
|
||||
) from e
|
||||
@@ -0,0 +1,114 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_issue
|
||||
description: |
|
||||
Get detailed information for a specific JIRA issue by its key.
|
||||
Returns comprehensive issue information including all fields, assignee, reporter, etc.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Issue
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: issue_key
|
||||
type: string
|
||||
description: |
|
||||
The issue key (e.g., 'RD-123', 'TEST-456').
|
||||
This is the unique identifier for the issue visible in the Jira UI.
|
||||
examples: [
|
||||
"RD-123",
|
||||
"TEST-456",
|
||||
"PROJ-789"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The issue key
|
||||
id:
|
||||
type: string
|
||||
description: The issue ID
|
||||
summary:
|
||||
type: string
|
||||
description: The issue summary
|
||||
description:
|
||||
type: string
|
||||
description: The issue description
|
||||
status:
|
||||
type: string
|
||||
description: The current status
|
||||
assignee:
|
||||
type: string
|
||||
description: The assignee display name
|
||||
assignee_account_id:
|
||||
type: string
|
||||
description: The assignee account ID
|
||||
reporter:
|
||||
type: string
|
||||
description: The reporter display name
|
||||
reporter_account_id:
|
||||
type: string
|
||||
description: The reporter account ID
|
||||
issue_type:
|
||||
type: string
|
||||
description: The issue type
|
||||
priority:
|
||||
type: string
|
||||
description: The priority level
|
||||
resolution:
|
||||
type: string
|
||||
description: The resolution
|
||||
resolution_date:
|
||||
type: string
|
||||
description: The resolution date
|
||||
created:
|
||||
type: string
|
||||
description: The creation date
|
||||
updated:
|
||||
type: string
|
||||
description: The last update date
|
||||
due_date:
|
||||
type: string
|
||||
description: The due date
|
||||
labels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The issue labels
|
||||
components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The issue components
|
||||
fix_versions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: The fix versions
|
||||
project:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description: The project information
|
||||
parent:
|
||||
type: string
|
||||
description: The parent issue key
|
||||
url:
|
||||
type: string
|
||||
description: The issue URL
|
||||
tests:
|
||||
- name: "Get issue by key"
|
||||
description: "Verify issue retrieval returns expected structure"
|
||||
arguments:
|
||||
- key: issue_key
|
||||
value: "RD-15333"
|
||||
@@ -0,0 +1,76 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project
|
||||
description: |
|
||||
Get details for a specific project by its key.
|
||||
Returns comprehensive project information including description, settings, and lead.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key (e.g., 'TEST' for project TEST).
|
||||
This is the short identifier for the project.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The project key
|
||||
name:
|
||||
type: string
|
||||
description: The project name
|
||||
description:
|
||||
type: string
|
||||
description: The project description
|
||||
projectTypeKey:
|
||||
type: string
|
||||
description: The project type key
|
||||
simplified:
|
||||
type: boolean
|
||||
description: Whether the project is simplified
|
||||
style:
|
||||
type: string
|
||||
description: The project style
|
||||
isPrivate:
|
||||
type: boolean
|
||||
description: Whether the project is private
|
||||
archived:
|
||||
type: boolean
|
||||
description: Whether the project is archived
|
||||
lead:
|
||||
type: object
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
emailAddress:
|
||||
type: string
|
||||
accountId:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
description: The project lead information
|
||||
url:
|
||||
type: string
|
||||
description: The project URL
|
||||
tests:
|
||||
- name: "Get project by key"
|
||||
description: "Verify project retrieval returns expected structure"
|
||||
arguments:
|
||||
- key: project_key
|
||||
value: "RD"
|
||||
@@ -0,0 +1,78 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project_role_users
|
||||
description: |
|
||||
Get users and groups for a specific role in a project.
|
||||
Returns detailed information about users and groups assigned to the role.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project Role Users
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key (e.g., 'TEST' for project TEST).
|
||||
This is the short identifier for the project.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
- name: role_name
|
||||
type: string
|
||||
description: |
|
||||
The name of the role to get users for.
|
||||
Common roles include 'Administrators', 'Developers', 'Users'.
|
||||
examples: [
|
||||
"Administrators",
|
||||
"Developers",
|
||||
"Users"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
project_key:
|
||||
type: string
|
||||
description: The project key
|
||||
role_name:
|
||||
type: string
|
||||
description: The role name
|
||||
role_id:
|
||||
type: string
|
||||
description: The role ID
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
description: List of users in the role
|
||||
groups:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
groupId:
|
||||
type: string
|
||||
description: List of groups in the role
|
||||
tests:
|
||||
- name: "Get role users"
|
||||
description: "Verify role users returns expected structure"
|
||||
arguments:
|
||||
- key: project_key
|
||||
value: "RD"
|
||||
- key: role_name
|
||||
value: "Administrators"
|
||||
@@ -0,0 +1,45 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_project_roles
|
||||
description: |
|
||||
Get all roles available in a project.
|
||||
Returns a list of roles with their IDs and URLs.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get Project Roles
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: project_key
|
||||
type: string
|
||||
description: |
|
||||
The project key (e.g., 'TEST' for project TEST).
|
||||
This is the short identifier for the project.
|
||||
examples: [
|
||||
"TEST",
|
||||
"PROJ",
|
||||
"DEV"
|
||||
]
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The role name
|
||||
id:
|
||||
type: string
|
||||
description: The role ID
|
||||
tests:
|
||||
- name: "Get project roles"
|
||||
description: "Verify project roles returns array of roles"
|
||||
arguments:
|
||||
- key: project_key
|
||||
value: "RD"
|
||||
@@ -0,0 +1,53 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: get_user
|
||||
description: |
|
||||
Get a specific user by their unique account ID.
|
||||
Returns detailed user information including display name, email, and account status.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Get User
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: account_id
|
||||
type: string
|
||||
description: |
|
||||
The unique Atlassian account ID for the user.
|
||||
This is typically in the format: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
You can get account IDs from other API calls like get_issue or search_user.
|
||||
examples: [
|
||||
"557058:ab168c94-8485-405c-88e6-6458375eb30b",
|
||||
"5b10ac8d82e05b22cc7d4ef5",
|
||||
"712020:0e99e8b3-7b3a-4b7c-9a1f-9e5d8c7b4a3e"
|
||||
]
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
description: The account ID
|
||||
displayName:
|
||||
type: string
|
||||
description: The display name
|
||||
emailAddress:
|
||||
type: string
|
||||
description: The email address
|
||||
active:
|
||||
type: boolean
|
||||
description: Whether the user is active
|
||||
timeZone:
|
||||
type: string
|
||||
description: The user's time zone
|
||||
tests:
|
||||
- name: "Get user by account ID"
|
||||
description: "Just run the tool"
|
||||
arguments:
|
||||
- key: account_id
|
||||
value: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
|
||||
@@ -0,0 +1,84 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: jql_query
|
||||
description: |
|
||||
Execute a JQL (Jira Query Language) query to search for issues in your Jira instance.
|
||||
Returns a list of issues with their details.
|
||||
Use the start and limit parameters to paginate through large result sets.
|
||||
type: tool
|
||||
annotations:
|
||||
title: JQL Query
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: |
|
||||
The JQL query string to execute. Examples:
|
||||
- "project = TEST" to find all issues in the TEST project
|
||||
- "assignee = currentUser()" to find issues assigned to you
|
||||
- "status = 'In Progress'" to find issues in progress
|
||||
examples: [
|
||||
"project = TEST",
|
||||
"status = 'In Progress'",
|
||||
"project = TEST AND status = 'Done'",
|
||||
"created >= -30d ORDER BY created DESC"
|
||||
]
|
||||
- name: start
|
||||
type: integer
|
||||
description: |
|
||||
The index of the first result to return (0-based).
|
||||
Use this for pagination: start=0 for first page, start=50 for second page, etc.
|
||||
Defaults to 0 if not specified.
|
||||
default: 0
|
||||
examples: [0, 50, 100]
|
||||
- name: limit
|
||||
type: integer
|
||||
description: |
|
||||
Maximum number of results to return.
|
||||
If not specified, returns all matching results.
|
||||
Recommended to use with start parameter for pagination.
|
||||
examples: [50, 100, 200]
|
||||
default: null
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
assignee:
|
||||
type: string
|
||||
reporter:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
updated:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
tests:
|
||||
- name: "Basic project query"
|
||||
description: "Verify JQL query returns array of issues"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "project = RD"
|
||||
- key: limit
|
||||
value: 1
|
||||
- name: "Status filter query"
|
||||
description: "Verify JQL query with status filter"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "status = 'In Progress'"
|
||||
- key: limit
|
||||
value: 1
|
||||
@@ -0,0 +1,42 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: list_projects
|
||||
description: |
|
||||
Return a concise list of Jira projects.
|
||||
Returns basic project information including key, name, type, and lead.
|
||||
type: tool
|
||||
annotations:
|
||||
title: List Projects
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters: []
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: The project key
|
||||
name:
|
||||
type: string
|
||||
description: The project name
|
||||
type:
|
||||
type: string
|
||||
description: The project type
|
||||
lead:
|
||||
type: string
|
||||
description: The project lead
|
||||
url:
|
||||
type: string
|
||||
description: The project URL
|
||||
tests:
|
||||
- name: "List all projects"
|
||||
description: "Verify projects list returns array of projects"
|
||||
arguments: []
|
||||
@@ -0,0 +1,56 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: search_user
|
||||
description: |
|
||||
Search for users by query string (username, email, or display name).
|
||||
Returns a list of matching users with their details.
|
||||
type: tool
|
||||
annotations:
|
||||
title: Search User
|
||||
readOnlyHint: true
|
||||
destructiveHint: false
|
||||
idempotentHint: true
|
||||
openWorldHint: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/jira_endpoints.py
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: |
|
||||
Search term - can be username, email, display name, or partial matches.
|
||||
The search is case-insensitive and supports partial matching.
|
||||
examples: [
|
||||
"ben@raw-labs.com",
|
||||
"Benjamin Gaidioz",
|
||||
"ben",
|
||||
"benjamin",
|
||||
"gaidioz"
|
||||
]
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
accountId:
|
||||
type: string
|
||||
description: The account ID
|
||||
displayName:
|
||||
type: string
|
||||
description: The display name
|
||||
emailAddress:
|
||||
type: string
|
||||
description: The email address
|
||||
active:
|
||||
type: boolean
|
||||
description: Whether the user is active
|
||||
timeZone:
|
||||
type: string
|
||||
description: The user's time zone
|
||||
tests:
|
||||
- name: "Search by name"
|
||||
description: "Verify user search by name returns results"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "Ben"
|
||||
@@ -0,0 +1,76 @@
|
||||
# Keycloak Authentication Example
|
||||
|
||||
This example demonstrates how to configure MXCP with Keycloak authentication.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A running Keycloak server (see quick start below)
|
||||
2. MXCP installed (`pip install mxcp`)
|
||||
|
||||
## Quick Start with Docker
|
||||
|
||||
Run Keycloak using Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 8080:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:latest start-dev
|
||||
```
|
||||
|
||||
## Keycloak Setup
|
||||
|
||||
1. Access the admin console at http://localhost:8080/admin
|
||||
2. Login with username: `admin`, password: `admin`
|
||||
3. Create a new realm (or use the default `master` realm)
|
||||
4. Create a new client:
|
||||
- Client ID: `mxcp-demo`
|
||||
- Client authentication: ON
|
||||
- Valid redirect URIs: `http://localhost:8000/*`
|
||||
5. Copy the client secret from the Credentials tab
|
||||
|
||||
## Configuration
|
||||
|
||||
Set environment variables:
|
||||
|
||||
```bash
|
||||
export KEYCLOAK_CLIENT_ID="mxcp-demo"
|
||||
export KEYCLOAK_CLIENT_SECRET="your-client-secret"
|
||||
export KEYCLOAK_REALM="master" # or your custom realm
|
||||
export KEYCLOAK_SERVER_URL="http://localhost:8080"
|
||||
```
|
||||
|
||||
## Running the Example
|
||||
|
||||
1. Start the MXCP server:
|
||||
```bash
|
||||
cd examples/keycloak
|
||||
mxcp serve --debug
|
||||
```
|
||||
|
||||
2. In another terminal, connect with the MCP client:
|
||||
```bash
|
||||
mcp connect http://localhost:8000
|
||||
```
|
||||
|
||||
3. You'll be redirected to Keycloak for authentication
|
||||
|
||||
## Testing Tools
|
||||
|
||||
Once authenticated, try running these example tools:
|
||||
|
||||
```bash
|
||||
# Get current user info
|
||||
mcp run tool get_user_info
|
||||
|
||||
# Query data with user context
|
||||
mcp run resource user_data
|
||||
```
|
||||
|
||||
## Production Considerations
|
||||
|
||||
- Use HTTPS for all URLs in production
|
||||
- Configure proper redirect URIs
|
||||
- Set up appropriate Keycloak realm roles and permissions
|
||||
- Enable refresh token rotation
|
||||
- Configure session timeouts
|
||||
@@ -0,0 +1,26 @@
|
||||
mxcp: 1
|
||||
projects:
|
||||
keycloak-demo:
|
||||
profiles:
|
||||
dev:
|
||||
secrets:
|
||||
- name: keycloak_creds
|
||||
type: oauth
|
||||
parameters:
|
||||
provider: keycloak
|
||||
auth:
|
||||
provider: keycloak
|
||||
keycloak:
|
||||
client_id: "${KEYCLOAK_CLIENT_ID}"
|
||||
client_secret: "${KEYCLOAK_CLIENT_SECRET}"
|
||||
realm: "${KEYCLOAK_REALM}"
|
||||
server_url: "${KEYCLOAK_SERVER_URL}"
|
||||
scope: "openid profile email"
|
||||
callback_path: "/keycloak/callback"
|
||||
clients:
|
||||
- client_id: "mcp-cli"
|
||||
name: "MCP CLI Client"
|
||||
redirect_uris:
|
||||
- "http://127.0.0.1:49153/oauth/callback"
|
||||
scopes:
|
||||
- "mxcp:access"
|
||||
@@ -0,0 +1,5 @@
|
||||
mxcp: 1
|
||||
project: keycloak-demo
|
||||
profile: dev
|
||||
secrets:
|
||||
- keycloak_creds
|
||||
@@ -0,0 +1,23 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: get_user_info
|
||||
description: "Get information about the authenticated user"
|
||||
parameters: []
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: "Username of the authenticated user"
|
||||
email:
|
||||
type: string
|
||||
description: "Email of the authenticated user"
|
||||
provider:
|
||||
type: string
|
||||
description: "Authentication provider used"
|
||||
source:
|
||||
code: |
|
||||
SELECT
|
||||
get_username() as username,
|
||||
get_user_email() as email,
|
||||
get_user_provider() as provider
|
||||
82
skills/mxcp-expert/assets/project-templates/plugin/README.md
Normal file
82
skills/mxcp-expert/assets/project-templates/plugin/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# MXCP Plugin Example
|
||||
|
||||
This example demonstrates how to create and use a custom MXCP plugin. The plugin provides various UDFs (User Defined Functions) that can be used in your SQL queries.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin implements the Caesar cipher, a simple encryption technique where each letter in the plaintext is shifted by a fixed number of positions in the alphabet.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
examples/plugin/
|
||||
├── plugins/
|
||||
│ └── my_plugin/
|
||||
│ └── __init__.py # Plugin implementation
|
||||
├── tools/
|
||||
│ └── decipher.yml # Endpoint using the plugin
|
||||
├── python/ # Directory for Python endpoints
|
||||
├── sql/ # Directory for SQL implementations
|
||||
├── config.yml # Example plugin configuration
|
||||
├── mxcp-site.yml # Project configuration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. User Configuration
|
||||
|
||||
The example includes two plugin configurations in `config.yml`:
|
||||
- `rot1`: Rotates letters by 1 position (A->B, B->C, etc.)
|
||||
- `rot10`: Rotates letters by 10 positions (A->K, B->L, etc.)
|
||||
|
||||
To use the plugin, register these configurations in your MXCP user config (`~/.mxcp/config.yml`):
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
|
||||
projects:
|
||||
demo-plugin:
|
||||
profiles:
|
||||
dev:
|
||||
plugin:
|
||||
config:
|
||||
rot1:
|
||||
rotation: "1"
|
||||
rot10:
|
||||
rotation: "10"
|
||||
```
|
||||
|
||||
Then in your `mxcp-site.yml`, you can reference one of these configurations:
|
||||
|
||||
```yaml
|
||||
mxcp: 1
|
||||
project: demo-plugin
|
||||
profile: dev
|
||||
plugin:
|
||||
- name: str_secret
|
||||
module: my_plugin
|
||||
config: rot1
|
||||
- name: tricky
|
||||
module: my_plugin
|
||||
config: rot10
|
||||
```
|
||||
|
||||
## Running the MCP
|
||||
|
||||
To run the service using the example configuration:
|
||||
|
||||
1. Set the `MXCP_CONFIG` environment variable to point to the example's config file:
|
||||
```bash
|
||||
export MXCP_CONFIG=/path/to/examples/plugin/config.yml
|
||||
```
|
||||
|
||||
2. Start the MXCP server:
|
||||
```bash
|
||||
mxcp serve
|
||||
```
|
||||
|
||||
The service will now use the example configuration with both the `simple` (rot1) and `tricky` (rot10) Caesar cipher plugins.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
mxcp: 1
|
||||
|
||||
projects:
|
||||
demo-plugin:
|
||||
profiles:
|
||||
dev:
|
||||
plugin:
|
||||
config:
|
||||
rot1:
|
||||
rotation: "1" # Rotate by 1 position (A->B, B->C, etc.)
|
||||
rot10:
|
||||
rotation: "10" # Rotate by 10 positions (A->K, B->L, etc.)
|
||||
@@ -0,0 +1,7 @@
|
||||
mxcp: 1
|
||||
project: demo-plugin
|
||||
profile: dev
|
||||
plugin:
|
||||
- name: str_secret
|
||||
module: my_plugin
|
||||
config: rot1
|
||||
@@ -0,0 +1,33 @@
|
||||
# MXCP Plugins Directory
|
||||
|
||||
This directory contains MXCP plugins that extend DuckDB with custom User Defined Functions (UDFs).
|
||||
|
||||
## Structure
|
||||
|
||||
Each plugin should be a Python module containing a class named `MXCPPlugin` that inherits from `MXCPBasePlugin`.
|
||||
|
||||
```
|
||||
plugins/
|
||||
├── my_plugin/
|
||||
│ └── __init__.py # Contains MXCPPlugin class
|
||||
├── utils/
|
||||
│ └── string_utils.py
|
||||
└── integrations/
|
||||
└── api_plugin.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Plugins are referenced in `mxcp-site.yml`:
|
||||
|
||||
```yaml
|
||||
plugin:
|
||||
- name: cipher
|
||||
module: my_plugin
|
||||
config: rot13
|
||||
```
|
||||
|
||||
The functions are then available in SQL as `{function_name}_{plugin_name}`:
|
||||
```sql
|
||||
SELECT encrypt_cipher('hello world');
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Example MXCP Plugin
|
||||
|
||||
This plugin demonstrates how to create a simple MXCP plugin with Caesar cipher encryption capabilities
|
||||
and optional user context integration for authentication-aware features.
|
||||
|
||||
Example usage:
|
||||
>>> plugin = MXCPPlugin({"rotation": 13})
|
||||
>>> plugin.encrypt("Hello, World!") # Returns "Uryyb, Jbeyq!"
|
||||
>>> plugin.decrypt("Uryyb, Jbeyq!") # Returns "Hello, World!"
|
||||
|
||||
With user context (when authentication is enabled):
|
||||
>>> plugin.get_user_info() # Returns user information
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from mxcp.plugins import MXCPBasePlugin, udf
|
||||
|
||||
|
||||
class MXCPPlugin(MXCPBasePlugin):
|
||||
"""Plugin that provides Caesar cipher encryption and decryption functions.
|
||||
|
||||
This plugin implements the Caesar cipher, a type of substitution cipher where
|
||||
each letter in the plaintext is shifted a certain number of places down or up
|
||||
the alphabet. It also demonstrates how to use user context for authentication-aware features.
|
||||
|
||||
Example:
|
||||
>>> plugin = MXCPPlugin({"rotation": 13})
|
||||
>>> plugin.encrypt("Hello, World!") # Returns "Uryyb, Jbeyq!"
|
||||
>>> plugin.decrypt("Uryyb, Jbeyq!") # Returns "Hello, World!"
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], user_context=None):
|
||||
"""Initialize the plugin with configuration and optional user context.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- rotation: Number of positions to shift (1-25), can be string or int
|
||||
user_context: Optional authenticated user context (for new plugins)
|
||||
"""
|
||||
super().__init__(config, user_context)
|
||||
rotation = config.get("rotation", 13)
|
||||
|
||||
# Convert string to int if needed
|
||||
if isinstance(rotation, str):
|
||||
try:
|
||||
rotation = int(rotation)
|
||||
except ValueError:
|
||||
raise ValueError("Rotation must be a valid integer")
|
||||
|
||||
if not isinstance(rotation, int) or rotation < 1 or rotation > 25:
|
||||
raise ValueError("Rotation must be an integer between 1 and 25")
|
||||
|
||||
self.rotation = rotation
|
||||
|
||||
def __rotate_char(self, char: str, forward: bool = True) -> str:
|
||||
"""Rotate a single character by the configured number of positions.
|
||||
|
||||
Args:
|
||||
char: Character to rotate
|
||||
forward: True for encryption, False for decryption
|
||||
|
||||
Returns:
|
||||
Rotated character
|
||||
"""
|
||||
if not char.isalpha():
|
||||
return char
|
||||
|
||||
# Determine the base ASCII value (a=97, A=65)
|
||||
base = ord("a") if char.islower() else ord("A")
|
||||
# Calculate the position in the alphabet (0-25)
|
||||
pos = ord(char) - base
|
||||
# Apply rotation (forward or backward)
|
||||
shift = self.rotation if forward else -self.rotation
|
||||
# Wrap around the alphabet and convert back to character
|
||||
return chr(base + ((pos + shift) % 26))
|
||||
|
||||
@udf
|
||||
def encrypt(self, text: str) -> str:
|
||||
"""Encrypt text using the Caesar cipher.
|
||||
|
||||
Args:
|
||||
text: Text to encrypt
|
||||
|
||||
Returns:
|
||||
Encrypted text
|
||||
|
||||
Example:
|
||||
>>> plugin.encrypt("Hello, World!") # Returns "Uryyb, Jbeyq!"
|
||||
"""
|
||||
return "".join(self.__rotate_char(c, True) for c in text)
|
||||
|
||||
@udf
|
||||
def decrypt(self, text: str) -> str:
|
||||
"""Decrypt text using the Caesar cipher.
|
||||
|
||||
Args:
|
||||
text: Text to decrypt
|
||||
|
||||
Returns:
|
||||
Decrypted text
|
||||
|
||||
Example:
|
||||
>>> plugin.decrypt("Uryyb, Jbeyq!") # Returns "Hello, World!"
|
||||
"""
|
||||
return "".join(self.__rotate_char(c, False) for c in text)
|
||||
|
||||
@udf
|
||||
def encrypt_with_user_key(self, text: str) -> str:
|
||||
"""Encrypt text using a user-specific rotation based on their username.
|
||||
|
||||
This demonstrates how plugins can use user context to provide
|
||||
personalized functionality.
|
||||
|
||||
Args:
|
||||
text: Text to encrypt
|
||||
|
||||
Returns:
|
||||
Text encrypted with user-specific key, or standard encryption if not authenticated
|
||||
"""
|
||||
if self.is_authenticated():
|
||||
# Use username length as additional rotation factor
|
||||
username = self.get_username() or ""
|
||||
user_rotation = (self.rotation + len(username)) % 26
|
||||
# Temporarily modify rotation for this operation
|
||||
original_rotation = self.rotation
|
||||
self.rotation = user_rotation if user_rotation > 0 else 1
|
||||
result = self.encrypt(text)
|
||||
self.rotation = original_rotation # Restore original rotation
|
||||
return result
|
||||
else:
|
||||
# Fall back to standard encryption
|
||||
return self.encrypt(text)
|
||||
@@ -0,0 +1,20 @@
|
||||
mxcp: 1
|
||||
|
||||
tool:
|
||||
name: "decipher"
|
||||
description: "Decrypt an encrypted message."
|
||||
parameters:
|
||||
- name: message
|
||||
type: string
|
||||
description: "Encrypted message"
|
||||
return:
|
||||
type: string
|
||||
source:
|
||||
code: SELECT decrypt_str_secret($message);
|
||||
annotations:
|
||||
readOnlyHint: true
|
||||
tests:
|
||||
- name: quick_check
|
||||
arguments:
|
||||
- key: message
|
||||
value: "usbmbmb"
|
||||
@@ -0,0 +1,58 @@
|
||||
# Python Endpoints Demo
|
||||
|
||||
This example demonstrates how to create and use Python-based endpoints in MXCP.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### 1. Basic Python Functions
|
||||
- `analyze_numbers` - Statistical analysis with various operations
|
||||
- `create_sample_data` - Database operations from Python
|
||||
|
||||
### 2. Async Functions
|
||||
- `process_time_series` - Demonstrates async Python endpoint
|
||||
|
||||
### 3. Database Access
|
||||
- Using `mxcp.runtime.db` to execute SQL queries
|
||||
- Parameter binding for safe SQL execution
|
||||
|
||||
## Running the Examples
|
||||
|
||||
In a terminal, test the endpoints:
|
||||
|
||||
```bash
|
||||
# Create sample data
|
||||
mxcp run tool create_sample_data --param table_name=test_data --param row_count=100
|
||||
|
||||
# Analyze numbers
|
||||
mxcp run tool analyze_numbers --param numbers="[1, 2, 3, 4, 5]" --param operation=mean
|
||||
|
||||
# Process time series (async function)
|
||||
mxcp run tool process_time_series --param table_name=test_data --param window_days=7
|
||||
```
|
||||
|
||||
Or, if you prefer, you can also start the MXCP server and use any MCP client to call the tools:
|
||||
```bash
|
||||
mxcp serve
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
python-demo/
|
||||
├── mxcp-site.yml # Project configuration
|
||||
├── python/ # Python modules
|
||||
│ └── data_analysis.py # Python endpoint implementations
|
||||
├── tools/ # Tool definitions
|
||||
│ ├── analyze_numbers.yml
|
||||
│ ├── create_sample_data.yml
|
||||
│ └── process_time_series.yml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
1. **Language Declaration**: Set `language: python` in the tool definition
|
||||
2. **Function Names**: The function name must match the tool name
|
||||
3. **Return Types**: Functions must return data matching the declared return type
|
||||
4. **Database Access**: Use `db.execute()` for SQL queries
|
||||
5. **Async Support**: Both sync and async functions are supported
|
||||
@@ -0,0 +1,3 @@
|
||||
mxcp: 1
|
||||
project: python-demo
|
||||
profile: default
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Example Python endpoints for data analysis.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from mxcp.runtime import config, db
|
||||
|
||||
|
||||
def analyze_numbers(numbers: list, operation: str = "mean") -> dict:
|
||||
"""
|
||||
Analyze a list of numbers with various statistical operations.
|
||||
"""
|
||||
if not numbers:
|
||||
return {"error": "No numbers provided"}
|
||||
|
||||
operations = {
|
||||
"mean": statistics.mean,
|
||||
"median": statistics.median,
|
||||
"mode": statistics.mode,
|
||||
"stdev": statistics.stdev if len(numbers) > 1 else lambda x: 0,
|
||||
"sum": sum,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
|
||||
if operation not in operations:
|
||||
return {"error": f"Unknown operation: {operation}"}
|
||||
|
||||
try:
|
||||
result = operations[operation](numbers)
|
||||
return {"operation": operation, "result": result, "count": len(numbers)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def create_sample_data(table_name: str, row_count: int) -> dict:
|
||||
"""
|
||||
Create a sample table with test data.
|
||||
"""
|
||||
try:
|
||||
# Drop table if exists
|
||||
db.execute(f"DROP TABLE IF EXISTS {table_name}")
|
||||
|
||||
# Create table
|
||||
db.execute(
|
||||
f"""
|
||||
CREATE TABLE {table_name} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR,
|
||||
value DOUBLE,
|
||||
category VARCHAR,
|
||||
created_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Insert sample data
|
||||
for i in range(row_count):
|
||||
db.execute(
|
||||
f"""
|
||||
INSERT INTO {table_name} (id, name, value, category, created_at)
|
||||
VALUES (
|
||||
$id,
|
||||
'Item ' || $item_num,
|
||||
ROUND(RANDOM() * 1000, 2),
|
||||
CASE
|
||||
WHEN RANDOM() < 0.33 THEN 'A'
|
||||
WHEN RANDOM() < 0.66 THEN 'B'
|
||||
ELSE 'C'
|
||||
END,
|
||||
CURRENT_TIMESTAMP - INTERVAL ($days || ' days')
|
||||
)
|
||||
""",
|
||||
{"id": i + 1, "item_num": i + 1, "days": i % 30},
|
||||
)
|
||||
|
||||
return {"status": "success", "table": table_name, "rows_created": row_count}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
def aggregate_by_category(table_name: str) -> list:
|
||||
"""
|
||||
Aggregate data by category from a table.
|
||||
"""
|
||||
try:
|
||||
results = db.execute(
|
||||
f"""
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*) as count,
|
||||
ROUND(AVG(value), 2) as avg_value,
|
||||
ROUND(SUM(value), 2) as total_value,
|
||||
MIN(value) as min_value,
|
||||
MAX(value) as max_value
|
||||
FROM {table_name}
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
"""
|
||||
)
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
|
||||
async def process_time_series(table_name: str, window_days: int = 7) -> list:
|
||||
"""
|
||||
Async function to process time series data with rolling windows.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Simulate some async processing
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
results = db.execute(
|
||||
f"""
|
||||
WITH daily_data AS (
|
||||
SELECT
|
||||
DATE_TRUNC('day', created_at) as date,
|
||||
category,
|
||||
COUNT(*) as daily_count,
|
||||
ROUND(AVG(value), 2) as daily_avg
|
||||
FROM {table_name}
|
||||
GROUP BY DATE_TRUNC('day', created_at), category
|
||||
)
|
||||
SELECT
|
||||
date,
|
||||
category,
|
||||
daily_count,
|
||||
daily_avg,
|
||||
ROUND(AVG(daily_avg) OVER (
|
||||
PARTITION BY category
|
||||
ORDER BY date
|
||||
ROWS BETWEEN {window_days - 1} PRECEDING AND CURRENT ROW
|
||||
), 2) as rolling_avg
|
||||
FROM daily_data
|
||||
ORDER BY date DESC, category
|
||||
LIMIT 50
|
||||
"""
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Example showing Python endpoints can return primitive arrays."""
|
||||
|
||||
|
||||
def show_primitive_arrays() -> list:
|
||||
"""Return Fibonacci sequence as array of numbers."""
|
||||
return [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
|
||||
|
||||
|
||||
def get_languages() -> list:
|
||||
"""Return list of programming languages."""
|
||||
return ["Python", "JavaScript", "Go", "Rust", "TypeScript"]
|
||||
|
||||
|
||||
def get_pi_digits() -> list:
|
||||
"""Return digits of pi."""
|
||||
return [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9]
|
||||
@@ -0,0 +1,28 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: aggregate_by_category
|
||||
description: Aggregate data by category with statistics
|
||||
language: python
|
||||
source:
|
||||
file: ../python/data_analysis.py
|
||||
parameters:
|
||||
- name: table_name
|
||||
type: string
|
||||
description: Name of the table to aggregate
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
count:
|
||||
type: integer
|
||||
avg_value:
|
||||
type: number
|
||||
total_value:
|
||||
type: number
|
||||
min_value:
|
||||
type: number
|
||||
max_value:
|
||||
type: number
|
||||
@@ -0,0 +1,27 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: analyze_numbers
|
||||
description: Analyze a list of numbers with statistical operations
|
||||
language: python
|
||||
source:
|
||||
file: ../python/data_analysis.py
|
||||
parameters:
|
||||
- name: numbers
|
||||
type: array
|
||||
items:
|
||||
type: number
|
||||
description: List of numbers to analyze
|
||||
- name: operation
|
||||
type: string
|
||||
enum: ["mean", "median", "mode", "stdev", "sum", "min", "max"]
|
||||
default: "mean"
|
||||
description: Statistical operation to perform
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
operation:
|
||||
type: string
|
||||
result:
|
||||
type: number
|
||||
count:
|
||||
type: integer
|
||||
@@ -0,0 +1,25 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: create_sample_data
|
||||
description: Create a sample table with test data
|
||||
language: python
|
||||
source:
|
||||
file: ../python/data_analysis.py
|
||||
parameters:
|
||||
- name: table_name
|
||||
type: string
|
||||
description: Name of the table to create
|
||||
- name: row_count
|
||||
type: integer
|
||||
description: Number of rows to generate
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
return:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
table:
|
||||
type: string
|
||||
rows_created:
|
||||
type: integer
|
||||
@@ -0,0 +1,21 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: process_time_series
|
||||
description: Process time series data with rolling window calculations (async)
|
||||
language: python
|
||||
source:
|
||||
file: ../python/data_analysis.py
|
||||
parameters:
|
||||
- name: table_name
|
||||
type: string
|
||||
description: Name of the table containing time series data
|
||||
- name: window_days
|
||||
type: integer
|
||||
default: 7
|
||||
description: Size of the rolling window in days
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
return:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -0,0 +1,185 @@
|
||||
# Salesforce OAuth Demo
|
||||
|
||||
This example demonstrates how to create MCP tools that interact with Salesforce using the MXCP OAuth authentication system with the `simple_salesforce` library.
|
||||
|
||||
## Features Demonstrated
|
||||
|
||||
### 1. MXCP OAuth Authentication
|
||||
- Project-wide Salesforce OAuth configuration
|
||||
- Automatic token management through MXCP authentication system
|
||||
- User authentication via standard OAuth 2.0 flow
|
||||
- Error handling for authentication failures
|
||||
|
||||
### 2. Salesforce API Integration
|
||||
- `list_sobjects` - Retrieve all available Salesforce objects (sObjects) from your org with optional filtering
|
||||
- `describe_sobject` - Get detailed metadata for a specific Salesforce object, including field information
|
||||
- `get_sobject` - Retrieve a specific Salesforce record by its ID
|
||||
- `search` - Search across all searchable Salesforce objects using simple search terms
|
||||
- `soql` - Execute SOQL (Salesforce Object Query Language) queries
|
||||
- `sosl` - Execute SOSL (Salesforce Object Search Language) queries for complex searches
|
||||
- `whoami` - Display information about the current authenticated Salesforce user
|
||||
- Token-based API access using authenticated user context
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Salesforce Org**: You need access to a Salesforce org (Developer Edition is fine)
|
||||
2. **Salesforce Connected App**: Create a Connected App in Salesforce with OAuth settings
|
||||
3. **Python Dependencies**: The `simple_salesforce` library (automatically managed by MXCP)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create Salesforce Connected App
|
||||
|
||||
1. Log into your Salesforce org
|
||||
2. Go to **Setup** → **App Manager** → **New Connected App**
|
||||
3. Fill in basic information:
|
||||
- **Connected App Name**: "MXCP Integration" (or your preferred name)
|
||||
- **API Name**: Will auto-populate
|
||||
- **Contact Email**: Your email
|
||||
4. Enable OAuth Settings:
|
||||
- **Enable OAuth Settings**: Check this box
|
||||
- **Callback URL**: This depends on your deployment:
|
||||
- **Local Development**: `http://localhost:8000/salesforce/callback`
|
||||
- **Remote/Production**: `https://your-domain.com/salesforce/callback` (replace with your actual server URL)
|
||||
- **Selected OAuth Scopes**: Add these scopes:
|
||||
- Access and manage your data (api)
|
||||
- Perform requests on your behalf at any time (refresh_token, offline_access)
|
||||
- Access your basic information (id, profile, email, address, phone)
|
||||
5. Save the Connected App
|
||||
6. Note down the **Consumer Key** (Client ID) and **Consumer Secret** (Client Secret)
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Set your Salesforce OAuth credentials:
|
||||
```bash
|
||||
export SALESFORCE_CLIENT_ID="your-consumer-key-from-connected-app"
|
||||
export SALESFORCE_CLIENT_SECRET="your-consumer-secret-from-connected-app"
|
||||
```
|
||||
|
||||
### 3. Configure Callback URL for Your Deployment
|
||||
|
||||
The callback URL configuration depends on where your MXCP server will run:
|
||||
|
||||
#### Local Development
|
||||
For local development, the default configuration in `config.yml` uses `http://localhost:8000/salesforce/callback`. This works when:
|
||||
- You're running MXCP locally on your development machine
|
||||
- Users authenticate from the same machine where MXCP is running
|
||||
|
||||
#### Remote/Production Deployment
|
||||
For remote servers or production deployments, you need to:
|
||||
|
||||
1. **Update config.yml**: Uncomment and modify the production callback URL:
|
||||
```yaml
|
||||
redirect_uris:
|
||||
- "http://localhost:8000/salesforce/callback" # Keep for local dev
|
||||
- "https://your-domain.com/salesforce/callback" # Add your actual URL
|
||||
```
|
||||
|
||||
2. **Update base_url**: Set the correct base URL in your config:
|
||||
```yaml
|
||||
transport:
|
||||
http:
|
||||
base_url: https://your-domain.com # Your actual server URL
|
||||
```
|
||||
|
||||
3. **Configure Connected App**: Add the production callback URL to your Salesforce Connected App's callback URLs
|
||||
|
||||
**Important**:
|
||||
- The callback URL must be accessible from the user's browser, not just from your server
|
||||
- For production deployments, Salesforce requires HTTPS for callback URLs
|
||||
- You can configure multiple callback URLs in your Connected App to support both local development and production
|
||||
|
||||
## Authenticate with Salesforce
|
||||
|
||||
When you first run MXCP, you'll need to authenticate with Salesforce:
|
||||
|
||||
```bash
|
||||
# Start the MXCP server with the config file - this will prompt for authentication
|
||||
MXCP_CONFIG=config.yml mxcp serve
|
||||
```
|
||||
|
||||
The authentication flow will:
|
||||
1. Open your browser to Salesforce login
|
||||
2. You'll log in with your Salesforce credentials
|
||||
3. Authorize the MXCP application
|
||||
4. Redirect back to complete authentication
|
||||
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
salesforce-oauth/
|
||||
├── mxcp-site.yml # Project metadata
|
||||
├── config.yml # Server and authentication configuration
|
||||
├── python/ # Python modules
|
||||
│ └── salesforce_client.py # Salesforce API implementations
|
||||
├── tools/ # Tool definitions
|
||||
│ ├── list_sobjects.yml # List all Salesforce objects
|
||||
│ ├── describe_sobject.yml # Get object metadata
|
||||
│ ├── get_sobject.yml # Get record by ID
|
||||
│ ├── search.yml # Search across objects
|
||||
│ ├── soql.yml # Execute SOQL queries
|
||||
│ ├── sosl.yml # Execute SOSL queries
|
||||
│ └── whoami.yml # Current user information
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
1. **MXCP OAuth Integration**: Uses MXCP's built-in Salesforce OAuth provider for secure authentication
|
||||
2. **User Context**: Access tokens are automatically managed and provided through `user_context()`
|
||||
3. **Token-based Authentication**: simple_salesforce is initialized with OAuth tokens instead of credentials
|
||||
4. **Project-wide Configuration**: Authentication is configured at the project level in `mxcp-site.yml`
|
||||
5. **Error Handling**: Comprehensive error handling for authentication and API failures
|
||||
6. **API Integration**: Demonstrates calling Salesforce REST API endpoints with proper OAuth tokens
|
||||
|
||||
## Example Output
|
||||
|
||||
When you run `list_sobjects`, you'll get a response like:
|
||||
|
||||
```json
|
||||
[
|
||||
"Account",
|
||||
"Contact",
|
||||
"Lead",
|
||||
"Opportunity",
|
||||
"Case",
|
||||
"Product2",
|
||||
"Task",
|
||||
"Event",
|
||||
"User",
|
||||
"CustomObject__c",
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
- **"No user context available"**: User needs to authenticate first by running `mxcp serve` and completing OAuth flow
|
||||
- **"No Salesforce access token found"**: Authentication was incomplete or token expired - re-authenticate
|
||||
- **Connected App Issues**: Verify your `SALESFORCE_CLIENT_ID` and `SALESFORCE_CLIENT_SECRET` are correct
|
||||
- **Callback URL Mismatch**: Ensure the callback URL in your Connected App matches where your MXCP server is accessible:
|
||||
- Local development: `http://localhost:8000/salesforce/callback`
|
||||
- Remote/production: `https://your-domain.com/salesforce/callback`
|
||||
- **OAuth Scopes**: Verify your Connected App has the required OAuth scopes (api, refresh_token, id, profile, email)
|
||||
|
||||
### API Errors
|
||||
- Verify you have the necessary permissions in Salesforce
|
||||
- Check that your org is accessible and not in maintenance mode
|
||||
- Ensure your Connected App is approved and not restricted by IP ranges
|
||||
|
||||
### Connected App Setup Issues
|
||||
- **App Not Found**: Make sure your Connected App is saved and the Consumer Key/Secret are copied correctly
|
||||
- **Callback URL**: The callback URL must exactly match your MXCP server's accessible address:
|
||||
- For local development: `http://localhost:8000/salesforce/callback`
|
||||
- For remote deployment: `https://your-domain.com/salesforce/callback`
|
||||
- **OAuth Scopes**: Missing scopes will cause authentication to fail - ensure all required scopes are selected
|
||||
|
||||
## Next Steps
|
||||
|
||||
This example demonstrates a comprehensive set of Salesforce integration tools. You could extend it with additional tools for data manipulation like:
|
||||
- `create_record` - Create new records in Salesforce objects
|
||||
- `update_record` - Update existing records
|
||||
- `delete_record` - Delete records
|
||||
- `bulk_operations` - Handle bulk data operations for large datasets
|
||||
@@ -0,0 +1,22 @@
|
||||
mxcp: 1
|
||||
transport:
|
||||
http:
|
||||
port: 8000
|
||||
host: 0.0.0.0
|
||||
# Set base_url to your server's public URL for production
|
||||
base_url: http://localhost:8000
|
||||
|
||||
projects:
|
||||
salesforce-oauth:
|
||||
profiles:
|
||||
default:
|
||||
# OAuth Authentication Configuration
|
||||
auth:
|
||||
provider: salesforce
|
||||
salesforce:
|
||||
client_id: "${SALESFORCE_CLIENT_ID}"
|
||||
client_secret: "${SALESFORCE_CLIENT_SECRET}"
|
||||
scope: "api refresh_token openid profile email"
|
||||
callback_path: "/salesforce/callback"
|
||||
auth_url: "https://login.salesforce.com/services/oauth2/authorize"
|
||||
token_url: "https://login.salesforce.com/services/oauth2/token"
|
||||
@@ -0,0 +1,3 @@
|
||||
mxcp: 1
|
||||
project: salesforce-oauth
|
||||
profile: default
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Salesforce MCP tools using simple_salesforce with MXCP OAuth authentication.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from functools import wraps
|
||||
from typing import Dict, Any, List, Optional
|
||||
from mxcp.sdk.auth.context import get_user_context
|
||||
from simple_salesforce import Salesforce # type: ignore[attr-defined]
|
||||
from simple_salesforce.exceptions import SalesforceExpiredSession
|
||||
from mxcp.runtime import on_init, on_shutdown
|
||||
import logging
|
||||
|
||||
# Thread-safe cache for Salesforce clients
|
||||
_client_cache: Optional[Dict[str, Salesforce]] = None
|
||||
_cache_lock: Optional[threading.Lock] = None
|
||||
|
||||
|
||||
@on_init
|
||||
def init_client_cache() -> None:
|
||||
"""
|
||||
Initialize the Salesforce client cache.
|
||||
"""
|
||||
global _client_cache, _cache_lock
|
||||
_client_cache = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
@on_shutdown
|
||||
def clear_client_cache() -> None:
|
||||
"""
|
||||
Clear the Salesforce client cache.
|
||||
"""
|
||||
global _client_cache, _cache_lock
|
||||
_client_cache = None
|
||||
_cache_lock = None
|
||||
|
||||
|
||||
def _get_cache_key(context: Any) -> Optional[str]:
|
||||
"""Generate a cache key based on user context."""
|
||||
if not context:
|
||||
return None
|
||||
|
||||
# Use user ID and instance URL as cache key
|
||||
user_id = getattr(context, "user_id", None) or getattr(context, "id", None)
|
||||
|
||||
# Extract instance URL
|
||||
instance_url = None
|
||||
if context.raw_profile and "urls" in context.raw_profile:
|
||||
urls = context.raw_profile["urls"]
|
||||
instance_url = urls.get("custom_domain")
|
||||
if not instance_url:
|
||||
for url_key in ["rest", "enterprise", "partner"]:
|
||||
if url_key in urls:
|
||||
service_url = urls[url_key]
|
||||
instance_url = service_url.split("/services/")[0]
|
||||
break
|
||||
|
||||
if user_id and instance_url:
|
||||
return f"{user_id}:{instance_url}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def with_session_retry(func: Any) -> Any:
|
||||
"""
|
||||
Decorator that automatically retries API calls with cache invalidation when sessions expire.
|
||||
|
||||
This handles the race condition where a session might expire between cache validation
|
||||
and the actual API call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except SalesforceExpiredSession:
|
||||
logging.error("Salesforce session expired")
|
||||
# Session expired during the call - invalidate cache and retry once
|
||||
context = get_user_context()
|
||||
cache_key = _get_cache_key(context)
|
||||
if cache_key and _cache_lock and _client_cache:
|
||||
with _cache_lock:
|
||||
# Remove the expired client from cache
|
||||
_client_cache.pop(cache_key, None)
|
||||
|
||||
# Retry the function call - this will get a fresh client
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _escape_sosl_search_term(search_term: str) -> str:
|
||||
"""
|
||||
Escape special characters in SOSL search terms to prevent injection attacks.
|
||||
|
||||
SOSL special characters that need escaping: & | ! { } [ ] ( ) ^ ~ * ? : " ' + -
|
||||
"""
|
||||
# Escape backslashes first to avoid double-escaping
|
||||
escaped = search_term.replace("\\", "\\\\")
|
||||
|
||||
# Escape SOSL special characters
|
||||
special_chars = [
|
||||
"&",
|
||||
"|",
|
||||
"!",
|
||||
"{",
|
||||
"}",
|
||||
"[",
|
||||
"]",
|
||||
"(",
|
||||
")",
|
||||
"^",
|
||||
"~",
|
||||
"*",
|
||||
"?",
|
||||
":",
|
||||
'"',
|
||||
"'",
|
||||
"+",
|
||||
"-",
|
||||
]
|
||||
for char in special_chars:
|
||||
escaped = escaped.replace(char, f"\\{char}")
|
||||
|
||||
return escaped
|
||||
|
||||
|
||||
def _get_salesforce_client() -> Salesforce:
|
||||
"""
|
||||
Create and return an authenticated Salesforce client using OAuth tokens from user_context.
|
||||
|
||||
Uses caching to avoid recreating clients unnecessarily. Clients are cached per user
|
||||
and instance URL combination in a thread-safe manner.
|
||||
"""
|
||||
try:
|
||||
# Get the authenticated user's context
|
||||
context = get_user_context()
|
||||
|
||||
if not context:
|
||||
raise ValueError("No user context available. User must be authenticated.")
|
||||
|
||||
# Generate cache key
|
||||
cache_key = _get_cache_key(context)
|
||||
|
||||
# Try to get cached client first
|
||||
if cache_key and _cache_lock and _client_cache:
|
||||
with _cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
logging.info("Using cached Salesforce client")
|
||||
# Return cached client - retry decorator will handle any session expiry
|
||||
return _client_cache[cache_key]
|
||||
|
||||
logging.info("No cached Salesforce client found, creating new one")
|
||||
# Extract Salesforce OAuth tokens from user context
|
||||
access_token = context.external_token
|
||||
|
||||
# Extract instance URL from user context (this is user/org-specific)
|
||||
instance_url = None
|
||||
if context.raw_profile and "urls" in context.raw_profile:
|
||||
urls = context.raw_profile["urls"]
|
||||
# Try custom_domain first (this is the full instance URL)
|
||||
instance_url = urls.get("custom_domain")
|
||||
if not instance_url:
|
||||
# Fallback: extract base URL from any service endpoint
|
||||
for url_key in ["rest", "enterprise", "partner"]:
|
||||
if url_key in urls:
|
||||
service_url = urls[url_key]
|
||||
instance_url = service_url.split("/services/")[0]
|
||||
break
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(
|
||||
"No Salesforce access token found in user context. "
|
||||
"User must authenticate with Salesforce through MXCP."
|
||||
)
|
||||
|
||||
if not instance_url:
|
||||
raise ValueError(
|
||||
"No Salesforce instance URL found in user context. "
|
||||
"Authentication may be incomplete or profile missing URL information."
|
||||
)
|
||||
|
||||
# Initialize Salesforce client with OAuth token
|
||||
sf = Salesforce(session_id=access_token, instance_url=instance_url)
|
||||
|
||||
# Cache the client if we have a valid cache key
|
||||
if cache_key and _cache_lock and _client_cache:
|
||||
with _cache_lock:
|
||||
_client_cache[cache_key] = sf
|
||||
|
||||
return sf
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to authenticate with Salesforce: {str(e)}")
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def list_sobjects(filter: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
List all available Salesforce objects (sObjects) in the org.
|
||||
|
||||
Args:
|
||||
filter: Optional fuzzy filter to match object names (case-insensitive substring search).
|
||||
Examples: "account", "__c" for custom objects, "contact", etc.
|
||||
|
||||
Returns:
|
||||
list: List of Salesforce object names as strings
|
||||
"""
|
||||
try:
|
||||
sf = _get_salesforce_client()
|
||||
|
||||
# Get all sObjects metadata
|
||||
describe_result = sf.describe()
|
||||
|
||||
if not describe_result or "sobjects" not in describe_result:
|
||||
raise ValueError("Invalid describe response from Salesforce API")
|
||||
|
||||
# Extract just the object names
|
||||
sobjects = describe_result["sobjects"]
|
||||
object_names = []
|
||||
for obj in sobjects:
|
||||
if not isinstance(obj, dict) or "name" not in obj:
|
||||
raise ValueError(f"Invalid sobject format: {obj}")
|
||||
object_names.append(obj["name"])
|
||||
|
||||
# Apply fuzzy filter if provided
|
||||
if filter is not None and filter.strip():
|
||||
filter_lower = filter.lower()
|
||||
object_names = [name for name in object_names if filter_lower in name.lower()]
|
||||
|
||||
# Sort alphabetically for consistent output
|
||||
object_names.sort()
|
||||
|
||||
return object_names
|
||||
|
||||
except Exception as e:
|
||||
# Return error in a format that can be handled by the caller
|
||||
raise Exception(f"Error listing Salesforce objects: {str(e)}")
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def describe_sobject(object_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed field information for a specific Salesforce object (sObject).
|
||||
|
||||
Args:
|
||||
object_name: The API name of the Salesforce object to describe
|
||||
|
||||
Returns:
|
||||
dict: Dictionary where each key is a field name and each value contains field metadata
|
||||
"""
|
||||
sf = _get_salesforce_client()
|
||||
|
||||
# Try to get the object - catch this specifically for "object doesn't exist"
|
||||
try:
|
||||
sobject = getattr(sf, object_name)
|
||||
except AttributeError:
|
||||
raise Exception(f"Salesforce object '{object_name}' does not exist")
|
||||
|
||||
# Let API errors from describe() propagate naturally with their original messages
|
||||
describe_result = sobject.describe()
|
||||
|
||||
if not describe_result or "fields" not in describe_result:
|
||||
raise ValueError(f"Invalid describe response for object '{object_name}'")
|
||||
|
||||
# Process fields into the required format
|
||||
fields_info = {}
|
||||
for field in describe_result["fields"]:
|
||||
if not isinstance(field, dict):
|
||||
raise ValueError(f"Invalid field format in '{object_name}': {field}")
|
||||
|
||||
required_fields = ["name", "type", "label"]
|
||||
for required_field in required_fields:
|
||||
if required_field not in field:
|
||||
raise ValueError(f"Field missing '{required_field}' in '{object_name}': {field}")
|
||||
field_name = field["name"]
|
||||
field_info = {"type": field["type"], "label": field["label"]}
|
||||
|
||||
# Add referenceTo information for reference fields
|
||||
if field["type"] == "reference" and field.get("referenceTo"):
|
||||
field_info["referenceTo"] = field["referenceTo"]
|
||||
|
||||
fields_info[field_name] = field_info
|
||||
|
||||
return fields_info
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def get_sobject(object_name: str, record_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve a specific Salesforce record by its object type and ID.
|
||||
|
||||
Args:
|
||||
object_name: The API name of the Salesforce object type
|
||||
record_id: The unique Salesforce ID of the record to retrieve
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing all fields and values for the specified record
|
||||
"""
|
||||
sf = _get_salesforce_client()
|
||||
|
||||
# Try to get the object - catch this specifically for "object doesn't exist"
|
||||
try:
|
||||
sobject = getattr(sf, object_name)
|
||||
except AttributeError:
|
||||
raise Exception(f"Salesforce object '{object_name}' does not exist")
|
||||
|
||||
# Let API errors from get() propagate naturally with their original messages
|
||||
record = sobject.get(record_id)
|
||||
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"Invalid record format returned for {object_name}:{record_id}")
|
||||
|
||||
# Remove 'attributes' field for consistency with other functions
|
||||
clean_record: Dict[str, Any] = {k: v for k, v in record.items() if k != "attributes"}
|
||||
return clean_record
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def soql(query: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute an arbitrary SOQL (Salesforce Object Query Language) query.
|
||||
|
||||
Args:
|
||||
query: The SOQL query to execute
|
||||
|
||||
Returns:
|
||||
list: Array of records returned by the SOQL query
|
||||
"""
|
||||
sf = _get_salesforce_client()
|
||||
|
||||
# Execute the SOQL query
|
||||
result = sf.query(query)
|
||||
|
||||
if not result or "records" not in result:
|
||||
raise ValueError("Invalid SOQL query response from Salesforce API")
|
||||
|
||||
# Remove 'attributes' field from each record for cleaner output
|
||||
records = []
|
||||
for record in result["records"]:
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"Invalid record format in SOQL result: {record}")
|
||||
clean_record = {k: v for k, v in record.items() if k != "attributes"}
|
||||
records.append(clean_record)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def search(search_term: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for records across all searchable Salesforce objects using a simple search term.
|
||||
Uses Salesforce's native search to automatically find matches across all objects.
|
||||
|
||||
Args:
|
||||
search_term: The term to search for across Salesforce objects
|
||||
|
||||
Returns:
|
||||
list: Array of matching records from various Salesforce objects
|
||||
"""
|
||||
sf = _get_salesforce_client()
|
||||
|
||||
# Escape the search term to prevent SOSL injection attacks
|
||||
escaped_search_term = _escape_sosl_search_term(search_term)
|
||||
|
||||
# Use simple SOSL syntax - Salesforce searches all searchable objects automatically
|
||||
sosl_query = f"FIND {{{escaped_search_term}}}"
|
||||
|
||||
# Execute the SOSL search
|
||||
search_results = sf.search(sosl_query)
|
||||
|
||||
if not search_results or "searchRecords" not in search_results:
|
||||
raise ValueError("Invalid SOSL search response from Salesforce API")
|
||||
|
||||
# Flatten results from all objects into a single array
|
||||
all_records = []
|
||||
for record in search_results["searchRecords"]:
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"Invalid record format in SOSL result: {record}")
|
||||
if "attributes" not in record or not isinstance(record["attributes"], dict):
|
||||
raise ValueError(f"Invalid record attributes in SOSL result: {record}")
|
||||
|
||||
# Remove 'attributes' field and add object type for context
|
||||
clean_record = {k: v for k, v in record.items() if k != "attributes"}
|
||||
clean_record["_ObjectType"] = record["attributes"]["type"]
|
||||
all_records.append(clean_record)
|
||||
|
||||
return all_records
|
||||
|
||||
|
||||
@with_session_retry
|
||||
def sosl(query: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute an arbitrary SOSL (Salesforce Object Search Language) query.
|
||||
|
||||
Args:
|
||||
query: The SOSL query to execute
|
||||
|
||||
Returns:
|
||||
list: Array of records returned by the SOSL search query
|
||||
"""
|
||||
sf = _get_salesforce_client()
|
||||
|
||||
# Execute the SOSL search
|
||||
search_results = sf.search(query)
|
||||
|
||||
if not search_results or "searchRecords" not in search_results:
|
||||
raise ValueError("Invalid SOSL query response from Salesforce API")
|
||||
|
||||
# Flatten results from all objects into a single array
|
||||
all_records = []
|
||||
for record in search_results["searchRecords"]:
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"Invalid record format in SOSL result: {record}")
|
||||
if "attributes" not in record or not isinstance(record["attributes"], dict):
|
||||
raise ValueError(f"Invalid record attributes in SOSL result: {record}")
|
||||
|
||||
# Remove 'attributes' field and add object type for context
|
||||
clean_record = {k: v for k, v in record.items() if k != "attributes"}
|
||||
clean_record["_ObjectType"] = record["attributes"]["type"]
|
||||
all_records.append(clean_record)
|
||||
|
||||
return all_records
|
||||
|
||||
|
||||
def whoami() -> Dict[str, Any]:
|
||||
"""
|
||||
Get basic information about the currently authenticated Salesforce user from the user context.
|
||||
|
||||
Returns essential user information from the MXCP authentication context without making API calls.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing essential current user information
|
||||
"""
|
||||
context = get_user_context()
|
||||
|
||||
if not context:
|
||||
raise ValueError("No user context available. User must be authenticated.")
|
||||
|
||||
# Extract instance URL from context
|
||||
instance_url = None
|
||||
if context.raw_profile and "urls" in context.raw_profile:
|
||||
urls = context.raw_profile["urls"]
|
||||
instance_url = urls.get("custom_domain")
|
||||
if not instance_url:
|
||||
# Fallback: extract base URL from any service endpoint
|
||||
for url_key in ["rest", "enterprise", "partner"]:
|
||||
if url_key in urls:
|
||||
service_url = urls[url_key]
|
||||
instance_url = service_url.split("/services/")[0]
|
||||
break
|
||||
|
||||
# Extract essential user information from raw profile
|
||||
raw_profile = context.raw_profile or {}
|
||||
|
||||
user_info = {
|
||||
"user_id": raw_profile.get("user_id"),
|
||||
"email": raw_profile.get("email"),
|
||||
"name": raw_profile.get("name"),
|
||||
"preferred_username": raw_profile.get("preferred_username"),
|
||||
"organization_id": raw_profile.get("organization_id"),
|
||||
"instanceUrl": instance_url,
|
||||
}
|
||||
|
||||
return user_info
|
||||
@@ -0,0 +1,34 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: describe_sobject
|
||||
description: |
|
||||
Get detailed metadata for a specific Salesforce object, including all field information.
|
||||
Returns field names, types, labels, and relationship details.
|
||||
tags: ["salesforce", "metadata", "schema"]
|
||||
annotations:
|
||||
title: "Describe Salesforce Object"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: object_name
|
||||
type: string
|
||||
description: "Name of the Salesforce object to describe"
|
||||
examples: ["Account", "Contact", "Opportunity", "Lead", "Case"]
|
||||
return:
|
||||
type: object
|
||||
description: "Object metadata with field information"
|
||||
additionalProperties: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/salesforce_client.py
|
||||
tests:
|
||||
- name: "Describe Account object"
|
||||
description: "Test describing the standard Account object"
|
||||
arguments:
|
||||
- key: object_name
|
||||
value: "Account"
|
||||
result_contains:
|
||||
Name:
|
||||
type: "string"
|
||||
Id:
|
||||
type: "id"
|
||||
@@ -0,0 +1,37 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: get_sobject
|
||||
description: |
|
||||
Retrieve a specific Salesforce record by its ID.
|
||||
Returns the complete record data with all accessible fields.
|
||||
tags: ["salesforce", "data", "records"]
|
||||
annotations:
|
||||
title: "Get Salesforce Record"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: object_name
|
||||
type: string
|
||||
description: "Name of the Salesforce object type"
|
||||
examples: ["Account", "Contact", "Opportunity", "Lead", "Case"]
|
||||
- name: record_id
|
||||
type: string
|
||||
description: "Salesforce record ID (15 or 18 character ID)"
|
||||
examples: ["001000000000001", "003000000000001AAA"]
|
||||
return:
|
||||
type: object
|
||||
description: "Complete record data"
|
||||
additionalProperties: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/salesforce_client.py
|
||||
tests:
|
||||
- name: "Get Account record"
|
||||
description: "Test retrieving an Account record by ID"
|
||||
arguments:
|
||||
- key: object_name
|
||||
value: "Account"
|
||||
- key: record_id
|
||||
value: "001000000000001"
|
||||
result_contains:
|
||||
Id: "001000000000001"
|
||||
@@ -0,0 +1,38 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: list_sobjects
|
||||
description: |
|
||||
List all available Salesforce objects (sObjects) in the organization.
|
||||
Optionally filter the list by providing a filter term for fuzzy matching on object names.
|
||||
tags: ["salesforce", "metadata", "objects"]
|
||||
annotations:
|
||||
title: "List Salesforce Objects"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: filter
|
||||
type: string
|
||||
description: "Optional filter term to match against object names (case-insensitive fuzzy matching)"
|
||||
default: null
|
||||
examples: ["Account", "Contact", "Custom"]
|
||||
return:
|
||||
type: array
|
||||
description: "List of sObject names"
|
||||
items:
|
||||
type: string
|
||||
description: "Name of a Salesforce object"
|
||||
language: python
|
||||
source:
|
||||
file: ../python/salesforce_client.py
|
||||
tests:
|
||||
- name: "List all objects"
|
||||
description: "Test listing all available Salesforce objects"
|
||||
arguments: []
|
||||
result_contains_item: "Account"
|
||||
|
||||
- name: "Filter objects"
|
||||
description: "Test filtering objects by name"
|
||||
arguments:
|
||||
- key: filter
|
||||
value: "Account"
|
||||
result_contains_item: "Account"
|
||||
@@ -0,0 +1,38 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: search
|
||||
description: |
|
||||
Search across all searchable Salesforce objects using the native Salesforce search.
|
||||
This uses the simple SOSL syntax "FIND {search_term}" which automatically searches
|
||||
all searchable objects and fields.
|
||||
language: python
|
||||
source:
|
||||
file: ../python/salesforce_client.py
|
||||
parameters:
|
||||
- name: search_term
|
||||
type: string
|
||||
description: "Term to search for across all searchable objects"
|
||||
examples: ["John", "Acme", "555-1234", "example.com"]
|
||||
return:
|
||||
type: array
|
||||
description: "Search results from all matching objects"
|
||||
items:
|
||||
type: object
|
||||
description: "Search result record"
|
||||
additionalProperties: true
|
||||
tags:
|
||||
- salesforce
|
||||
- search
|
||||
- data
|
||||
annotations:
|
||||
title: "Search Salesforce Records"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
tests:
|
||||
- name: "Basic search"
|
||||
description: "Test searching for a common term"
|
||||
arguments:
|
||||
- key: search_term
|
||||
value: "test"
|
||||
# Note: Using result type array since search results can be empty or contain records
|
||||
result: []
|
||||
@@ -0,0 +1,33 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: soql
|
||||
description: |
|
||||
Execute a SOQL (Salesforce Object Query Language) query. Returns query results as an array of records.
|
||||
For personalized queries (e.g., 'my tasks', 'my opportunities'), use the whoami tool first to get the current user's ID for filtering (e.g., WHERE OwnerId = 'user_id').
|
||||
tags: ["salesforce", "query", "data"]
|
||||
annotations:
|
||||
title: "Execute SOQL Query"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: "SOQL query string to execute"
|
||||
examples: ["SELECT Id, Name FROM Account LIMIT 10", "SELECT Id, Email FROM Contact WHERE LastName = 'Smith'"]
|
||||
return:
|
||||
type: array
|
||||
description: "Query results"
|
||||
items:
|
||||
type: object
|
||||
description: "Record data"
|
||||
additionalProperties: true
|
||||
language: python
|
||||
source:
|
||||
file: ../python/salesforce_client.py
|
||||
tests:
|
||||
- name: "Simple Account query"
|
||||
description: "Test executing a basic SOQL query on Account object"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "SELECT Id, Name FROM Account LIMIT 1"
|
||||
result_length: 1
|
||||
@@ -0,0 +1,36 @@
|
||||
mxcp: 1
|
||||
tool:
|
||||
name: sosl
|
||||
description: |
|
||||
Execute a raw SOSL (Salesforce Object Search Language) query.
|
||||
Allows complex search queries with specific object targeting and field selection.
|
||||
language: python
|
||||
source:
|
||||
file: ../python/salesforce_client.py
|
||||
parameters:
|
||||
- name: query
|
||||
type: string
|
||||
description: "SOSL query string to execute"
|
||||
examples: ["FIND {test} IN ALL FIELDS RETURNING Account(Id, Name)", "FIND {John} RETURNING Contact(Id, Name, Email)"]
|
||||
return:
|
||||
type: array
|
||||
description: "Search results"
|
||||
items:
|
||||
type: object
|
||||
description: "Search result record"
|
||||
additionalProperties: true
|
||||
tags:
|
||||
- salesforce
|
||||
- search
|
||||
- advanced
|
||||
annotations:
|
||||
title: "Execute SOSL Query"
|
||||
readOnlyHint: true
|
||||
idempotentHint: true
|
||||
tests:
|
||||
- name: "Simple SOSL query"
|
||||
description: "Test executing a basic SOSL search query"
|
||||
arguments:
|
||||
- key: query
|
||||
value: "FIND {test} IN ALL FIELDS RETURNING Account(Id, Name)"
|
||||
result: []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user