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:
Chris Coutinho
2025-11-03 19:45:47 +01:00
parent 636bfd416f
commit 71e77e95bc
18 changed files with 1819 additions and 647 deletions
+16 -16
View File
@@ -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 = {}
+7 -7
View File
@@ -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
)
+16 -16
View File
@@ -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)
+33 -33
View File
@@ -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,
+10 -10
View File
@@ -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(
+5 -5
View File
@@ -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
)
+6 -6
View File
@@ -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)
+11 -11
View File
@@ -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(