refactor: integrate token exchange into unified get_client() pattern
Resolves the token exchange implementation gap where get_session_client() was implemented but never used by tools. Unifies token acquisition into a single async get_client() method that handles both pass-through and token exchange modes transparently. Core Changes: - Make get_client() async and merge token exchange logic into it - Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes) - Update all 8 tool modules to use await get_client(ctx) - Fix provisioning decorator to skip checks in BasicAuth mode Token Acquisition Modes: 1. BasicAuth: Returns shared client (no token operations) 2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud 3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693 Key Architectural Clarifications: - Progressive Consent (Flow 1/2) = Authorization architecture - Token Exchange = Token acquisition pattern during tool execution - Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs) - Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP Documentation Updates: - ADR-004: Added comprehensive token acquisition patterns section - CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status - CLAUDE.md: Updated architectural patterns with async get_client() Testing: - All 36 unit tests passing - All 4 smoke tests passing (BasicAuth mode) - Linting issues fixed (ruff) Configuration: ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
calendars_data = await client.calendar.list_calendars()
|
||||
|
||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||
@@ -79,7 +79,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with event creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
event_data = {
|
||||
"title": title,
|
||||
@@ -139,7 +139,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of events matching the filters
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Convert YYYY-MM-DD format dates to datetime objects
|
||||
start_datetime = None
|
||||
@@ -214,7 +214,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
ctx: Context,
|
||||
):
|
||||
"""Get detailed information about a specific event"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||
return event_data
|
||||
|
||||
@@ -248,7 +248,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
etag: str = "",
|
||||
):
|
||||
"""Update any aspect of an existing event"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
event_data = {}
|
||||
@@ -299,7 +299,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
ctx: Context,
|
||||
):
|
||||
"""Delete a calendar event"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -342,7 +342,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with meeting creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Combine date and time for start_datetime
|
||||
start_datetime = f"{date}T{time}:00"
|
||||
@@ -377,7 +377,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
limit: int = 10,
|
||||
):
|
||||
"""Get upcoming events in next N days"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
now = dt.datetime.now()
|
||||
end_datetime = now + dt.timedelta(days=days_ahead)
|
||||
@@ -447,7 +447,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of available time slots with start/end times and duration
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Parse attendees
|
||||
attendee_list = []
|
||||
@@ -549,7 +549,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Summary of operation results including counts and details
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
if operation not in ["update", "delete", "move"]:
|
||||
raise ValueError("Operation must be 'update', 'delete', or 'move'")
|
||||
@@ -772,7 +772,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Result of the calendar management operation
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
if action == "list":
|
||||
return await client.calendar.list_calendars()
|
||||
@@ -839,7 +839,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of todos matching the filters
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
@@ -890,7 +890,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with todo creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
todo_data = {
|
||||
"summary": summary,
|
||||
@@ -939,7 +939,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with todo update result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
todo_data = {}
|
||||
@@ -981,7 +981,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with deletion status
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -1005,7 +1005,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
List of todos matching the filters from all calendars
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
|
||||
@@ -14,14 +14,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -35,7 +35,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
name: The name of the addressbook.
|
||||
display_name: The display name of the addressbook.
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.create_addressbook(
|
||||
name=name, display_name=display_name
|
||||
)
|
||||
@@ -44,7 +44,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -59,7 +59,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.create_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||
)
|
||||
@@ -68,7 +68,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -84,7 +84,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||
etag: Optional ETag for optimistic concurrency control.
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.update_contact(
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
async def cookbook_get_version():
|
||||
"""Get the Cookbook app and API version"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
version_data = await client.cookbook.get_version()
|
||||
return Version(**version_data)
|
||||
|
||||
@@ -41,7 +41,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
async def cookbook_get_config():
|
||||
"""Get the Cookbook app configuration"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
config_data = await client.cookbook.get_config()
|
||||
return CookbookConfig(**config_data)
|
||||
|
||||
@@ -49,7 +49,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
async def nc_cookbook_get_recipe_resource(recipe_id: int):
|
||||
"""Get a recipe by ID using resource URI"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
@@ -77,7 +77,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
|
||||
This extracts recipe data from websites that use schema.org Recipe markup.
|
||||
Many popular recipe sites support this standard."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.import_recipe(url)
|
||||
recipe = Recipe(**recipe_data)
|
||||
@@ -131,7 +131,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.list_recipes()
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -156,7 +156,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||
"""Get a specific recipe by its ID"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
@@ -199,7 +199,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
Optional: All other recipe fields following schema.org/Recipe format.
|
||||
|
||||
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
recipe_data = {"name": name}
|
||||
if description:
|
||||
@@ -276,7 +276,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Update an existing recipe.
|
||||
|
||||
Provide only the fields you want to update. Unspecified fields remain unchanged."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# First get the current recipe
|
||||
try:
|
||||
@@ -352,7 +352,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
) -> DeleteRecipeResponse:
|
||||
"""Delete a recipe permanently"""
|
||||
logger.info("Deleting recipe %s", recipe_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.delete_recipe(recipe_id)
|
||||
return DeleteRecipeResponse(
|
||||
@@ -386,7 +386,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
"""Search for recipes by keywords, tags, and categories"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.search_recipes(query)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -422,7 +422,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
categories_data = await client.cookbook.list_categories()
|
||||
categories = [Category(**c) for c in categories_data]
|
||||
@@ -451,7 +451,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Use '_' as the category name to get recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_in_category(category)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -483,7 +483,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
keywords_data = await client.cookbook.list_keywords()
|
||||
keywords = [Keyword(**k) for k in keywords_data]
|
||||
@@ -510,7 +510,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
"""Get all recipes that have specific keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
@@ -552,7 +552,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
folder: Recipe folder path in user's files
|
||||
update_interval: Automatic rescan interval in minutes
|
||||
print_image: Whether to print images with recipes"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
config_data = {}
|
||||
if folder is not None:
|
||||
@@ -587,7 +587,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
This rebuilds the search index and should be used after manual file changes."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.reindex()
|
||||
return ReindexResponse(status_code=200, message=message)
|
||||
|
||||
@@ -31,7 +31,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
"""List all Nextcloud Deck boards"""
|
||||
ctx: Context = mcp.get_context()
|
||||
await ctx.warning("This message is deprecated, use the deck_get_board instead")
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return [board.model_dump() for board in boards]
|
||||
|
||||
@@ -42,7 +42,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_board tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.model_dump()
|
||||
|
||||
@@ -53,7 +53,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_stacks tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return [stack.model_dump() for stack in stacks]
|
||||
|
||||
@@ -64,7 +64,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_stack tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack.model_dump()
|
||||
|
||||
@@ -75,7 +75,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_cards tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return [card.model_dump() for card in stack.cards]
|
||||
@@ -88,7 +88,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_card tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card.model_dump()
|
||||
|
||||
@@ -99,7 +99,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_labels tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return [label.model_dump() for label in board.labels]
|
||||
|
||||
@@ -110,7 +110,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
await ctx.warning(
|
||||
"This resource is deprecated, use the deck_get_label tool instead"
|
||||
)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
label = await client.deck.get_label(board_id, label_id)
|
||||
return label.model_dump()
|
||||
|
||||
@@ -120,7 +120,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
|
||||
@@ -128,7 +128,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||
"""Get details of a specific Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board
|
||||
|
||||
@@ -136,7 +136,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
|
||||
@@ -144,7 +144,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||
"""Get details of a specific Nextcloud Deck stack"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
return stack
|
||||
|
||||
@@ -154,7 +154,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
"""Get all cards in a Nextcloud Deck stack"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return stack.cards
|
||||
@@ -166,7 +166,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
"""Get details of a specific Nextcloud Deck card"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||
return card
|
||||
|
||||
@@ -174,7 +174,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
|
||||
@@ -182,7 +182,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||
"""Get details of a specific Nextcloud Deck label"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
label = await client.deck.get_label(board_id, label_id)
|
||||
return label
|
||||
|
||||
@@ -199,7 +199,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: The title of the new board
|
||||
color: The hexadecimal color of the new board (e.g. FF0000)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.create_board(title, color)
|
||||
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
|
||||
|
||||
@@ -217,7 +217,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: The title of the new stack
|
||||
order: Order for sorting the stacks
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.create_stack(board_id, title, order)
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@@ -238,7 +238,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: New title for the stack
|
||||
order: New order for the stack
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.update_stack(board_id, stack_id, title, order)
|
||||
return StackOperationResponse(
|
||||
success=True,
|
||||
@@ -258,7 +258,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id: The ID of the board
|
||||
stack_id: The ID of the stack
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.delete_stack(board_id, stack_id)
|
||||
return StackOperationResponse(
|
||||
success=True,
|
||||
@@ -291,7 +291,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
description: Description of the card
|
||||
duedate: Due date of the card (ISO-8601 format)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
card = await client.deck.create_card(
|
||||
board_id, stack_id, title, type, order, description, duedate
|
||||
)
|
||||
@@ -333,7 +333,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
archived: Whether the card should be archived
|
||||
done: Completion date for the card (ISO-8601 format)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
@@ -367,7 +367,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.delete_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -389,7 +389,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.archive_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -411,7 +411,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
stack_id: The ID of the stack
|
||||
card_id: The ID of the card
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -440,7 +440,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
order: New position in the target stack
|
||||
target_stack_id: The ID of the target stack
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.reorder_card(
|
||||
board_id, stack_id, card_id, order, target_stack_id
|
||||
)
|
||||
@@ -465,7 +465,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: The title of the new label
|
||||
color: The color of the new label (hex format without #)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
label = await client.deck.create_label(board_id, title, color)
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@@ -486,7 +486,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
title: New title for the label
|
||||
color: New color for the label (hex format without #)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.update_label(board_id, label_id, title, color)
|
||||
return LabelOperationResponse(
|
||||
success=True,
|
||||
@@ -506,7 +506,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
board_id: The ID of the board
|
||||
label_id: The ID of the label
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.delete_label(board_id, label_id)
|
||||
return LabelOperationResponse(
|
||||
success=True,
|
||||
@@ -529,7 +529,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
label_id: The ID of the label to assign
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -552,7 +552,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
label_id: The ID of the label to remove
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -576,7 +576,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
user_id: The user ID to assign
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
@@ -599,7 +599,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
card_id: The ID of the card
|
||||
user_id: The user ID to unassign
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
|
||||
return CardOperationResponse(
|
||||
success=True,
|
||||
|
||||
@@ -29,7 +29,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
settings_data = await client.notes.get_settings()
|
||||
return NotesSettings(**settings_data)
|
||||
|
||||
@@ -37,7 +37,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
@@ -59,7 +59,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
"""Get user note using note id"""
|
||||
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
@@ -92,7 +92,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note (requires notes:write scope)"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
title=title,
|
||||
@@ -149,7 +149,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
If the note has been modified by someone else since you retrieved it,
|
||||
the update will fail with a 412 error."""
|
||||
logger.info("Updating note %s", note_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.update(
|
||||
note_id=note_id,
|
||||
@@ -206,7 +206,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
between the note and what will be appended."""
|
||||
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.append_content(
|
||||
note_id=note_id, content=content
|
||||
@@ -252,7 +252,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
@require_provisioning
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
|
||||
@@ -298,7 +298,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
"""Get a specific note by its ID (requires notes:read scope)"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
@@ -329,7 +329,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
"""Get a specific attachment from a note"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
@@ -374,7 +374,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
try:
|
||||
await client.notes.delete_note(note_id)
|
||||
return DeleteNoteResponse(
|
||||
|
||||
@@ -45,7 +45,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with share information including share ID
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
share_data = await client.sharing.create_share(
|
||||
path=path,
|
||||
share_with=share_with,
|
||||
@@ -67,7 +67,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string confirming deletion
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
await client.sharing.delete_share(share_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
||||
@@ -87,7 +87,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with share information
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
share_data = await client.sharing.get_share(share_id)
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@@ -106,7 +106,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with list of shares
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
shares = await client.sharing.list_shares(
|
||||
path=path, shared_with_me=shared_with_me
|
||||
)
|
||||
@@ -133,7 +133,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
JSON string with updated share information
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
share_data = await client.sharing.update_share(
|
||||
share_id=share_id, permissions=permissions
|
||||
)
|
||||
|
||||
@@ -14,14 +14,14 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -33,7 +33,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
offset: int | None = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -43,7 +43,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -53,12 +53,12 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.delete_row(row_id)
|
||||
|
||||
@@ -28,7 +28,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
DirectoryListing with files, total_count, directories_count, files_count, and total_size
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
items = await client.webdav.list_directory(path)
|
||||
|
||||
# Convert to FileInfo models
|
||||
@@ -76,7 +76,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||
@@ -143,7 +143,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating success
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Handle base64 encoded content
|
||||
if content_type and "base64" in content_type.lower():
|
||||
@@ -167,7 +167,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -181,7 +181,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
@@ -199,7 +199,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.move_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
@@ -219,7 +219,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
return await client.webdav.copy_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
@@ -249,7 +249,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Build where conditions based on filters
|
||||
conditions = []
|
||||
@@ -355,7 +355,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.find_by_name(
|
||||
pattern=pattern, scope=scope, limit=limit
|
||||
)
|
||||
@@ -382,7 +382,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of matching files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.find_by_type(
|
||||
mime_type=mime_type, scope=scope, limit=limit
|
||||
)
|
||||
@@ -408,7 +408,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SearchFilesResponse with list of favorite files
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.list_favorites(scope=scope, limit=limit)
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
|
||||
Reference in New Issue
Block a user