Skip to content

MCP & Agent Integration Guide

ivcap-lambda tools can be exposed as a spec-compliant Model Context Protocol (MCP) server — built on the official mcp Python SDK, not a custom reimplementation — making them directly callable by AI agent frameworks and MCP hosts such as Claude Desktop, Cursor, Cline, and others.

This guide covers both ways people arrive at MCP support:

See also AGENTS.md for the same three-way split aimed at AI coding agents, routing to Track A, Track B, and Track C (migrating from the plain mcp SDK).

What is MCP?

The Model Context Protocol is an open standard that lets AI models discover and call tools via a standardised interface, supporting both HTTP (Streamable-HTTP) and stdio transports. By enabling MCP, your ivcap-lambda service becomes a first-class tool provider for any MCP-compatible host, in addition to (or instead of) being an IVCAP REST tool.

Adding MCP to an IVCAP Lambda Service

Install the optional mcp extra, then pass --with-mcp when starting the server:

pip install ivcap-lambda[mcp]
python my_service.py --with-mcp --port 8090

This mounts a /mcp endpoint (Streamable-HTTP transport) on the running FastAPI app, alongside your existing REST endpoints. It speaks the MCP protocol and exposes every registered @ivcap_lambda tool automatically — no changes are required in tool code.

Running as a stdio MCP server

For local development with stdio-based MCP hosts (Claude Desktop, Cline, the MCP Inspector's stdio mode) that launch your tool as a subprocess instead of connecting over HTTP, use --with-mcp-stdio instead of --with-mcp. This skips the FastAPI/uvicorn HTTP server entirely — no REST, Swagger, or health endpoints are started:

python my_service.py --with-mcp-stdio

--with-mcp and --with-mcp-stdio are mutually exclusive. Tool registration, schema generation, and progress-notification bridging are identical either way — the same tool code works unchanged over both transports.

Sync and async tool functions both work over MCP

A tool function registered with @ivcap_lambda can be either a plain def or an async def — this is detected automatically (via asyncio.iscoroutinefunction) and handled transparently by the same Executor regardless of which transport (REST or MCP) the call came in on:

@ivcap_lambda("/greet-async", opts=ToolOptions(tags=["Greeter"]))
async def greet_async(req: GreetRequest) -> GreetResult:
    """Greet a person asynchronously"""
    await asyncio.sleep(0)  # yield once
    return GreetResult(greeting=f"Hello, {req.name}!")

Calling greet_async over MCP's tools/call works exactly like calling a synchronous tool — no special handling, await, or extra configuration is required on the client side. See Async Tools in the Tool Functions Guide for more detail, and examples/test-mcp/mcp-service.py's echo tool for a complete, runnable example of an async def tool exposed over MCP.

Tool Description Endpoints

Even without --with-mcp, each tool has a GET endpoint that returns a description:

# Get tool description at the tool's path
curl http://localhost:8090/greet

This returns a JSON description suitable for agent frameworks that consume OpenAPI-style tool definitions. The description is generated from the function's docstring, Pydantic model fields, and ToolOptions.

Configuring the Service ID

When AI agents call your tool, they need a stable service ID to identify where to send follow-up requests. Set service_id in ToolOptions to a path; the server will prepend the public URL prefix automatically:

@ivcap_lambda("/greet", opts=ToolOptions(tags=["Greeter"], service_id="/greet"))
def greet(req: GreetRequest) -> GreetResult:
    """Greet a person

    Generates a personalised greeting.
    """
    ...

If the service is running behind a reverse proxy with the X-Forwarded-For or X-Forwarded-Proto headers, the public URL is detected automatically.

Writing Good Tool Descriptions

AI agents use the docstring to decide when and how to call your tool. Follow these conventions for maximum agent compatibility:

@ivcap_lambda("/analyse-sentiment", opts=ToolOptions(tags=["NLP"]))
def analyse_sentiment(req: SentimentRequest) -> SentimentResult:
    """Analyse the sentiment of a piece of text

    Given an input string, this tool returns a sentiment label
    (positive, negative, or neutral) and a confidence score between 0 and 1.

    Use this tool when you need to determine the emotional tone of user-provided
    text, product reviews, social media posts, or any other natural-language input.

    The tool processes text in English only. For multilingual text, pre-translate
    to English first.
    """
    ...

Key points: - First paragraph — one-line summary (used as the OpenAPI endpoint summary) - Body — detailed description of what the tool does, when to use it, and any constraints - Field descriptions — every request and result field must have a clear description in Field()

Connecting an MCP Client

Claude Desktop / Cline (stdio)

Stdio-based hosts launch your service as a subprocess, so point them at --with-mcp-stdio, not --with-mcp (which starts the HTTP transport instead). Add to your Claude Desktop configuration (~/.claude_desktop_config.json) or Cline's MCP settings:

{
  "mcpServers": {
    "my-lambda-service": {
      "command": "python",
      "args": ["my_service.py", "--with-mcp-stdio"],
      "cwd": "/absolute/path/to/your/service"
    }
  }
}

MCP Inspector (for development)

The MCP Inspector lets you test MCP servers interactively, against either transport:

# HTTP (server already running with --with-mcp)
npx @modelcontextprotocol/inspector http://localhost:8090/mcp

# stdio (inspector launches the server itself)
npx @modelcontextprotocol/inspector python my_service.py --with-mcp-stdio

Custom MCP Client (Python)

Use the official mcp SDK's Client to connect over Streamable-HTTP:

import asyncio
import mcp

async def main():
    async with mcp.Client("http://localhost:8090/mcp") as client:
        tools = await client.list_tools()
        print([t.name for t in tools.tools])

        result = await client.call_tool("greet", {"req": {"name": "IVCAP"}})
        print(result.structured_content)

asyncio.run(main())

Client can also launch a local server as a stdio subprocess directly — see the MCP Python SDK docs for StdioServerParameters.

Example: MCP-Ready Service

from pydantic import BaseModel, Field
from ivcap_service import Service, ServiceContact, with_schema
from ivcap_lambda import start_lambda_server, ivcap_lambda, ToolOptions, logging_init

logging_init()

service = Service(
    name="NLP Tools",
    contact=ServiceContact(name="Dev Team", email="dev@example.com"),
)


@with_schema("urn:example:schema:word-count.request.1")
class WordCountRequest(BaseModel):
    text: str = Field(..., description="The text to count words in.")


@with_schema("urn:example:schema:word-count.1")
class WordCountResult(BaseModel):
    word_count: int = Field(..., description="Number of words in the text.")
    char_count: int = Field(..., description="Number of characters in the text.")


@ivcap_lambda("/word-count", opts=ToolOptions(tags=["NLP"], service_id="/word-count"))
def word_count(req: WordCountRequest) -> WordCountResult:
    """Count words and characters in a piece of text

    Returns the word count and character count of the provided text.
    Use this tool when you need to check text length constraints or
    estimate reading time for a given piece of content.
    """
    return WordCountResult(
        word_count=len(req.text.split()),
        char_count=len(req.text),
    )


if __name__ == "__main__":
    start_lambda_server(service)

Start with MCP enabled:

python my_service.py --with-mcp --port 8090

MCP-First Development

If your primary goal is to build an MCP server — for Claude Desktop, Cursor, Cline, or any other MCP host — and the ability to also run on IVCAP is a bonus rather than the focus, you can start from ivcap-lambda without touching any IVCAP-specific concepts at all. The @ivcap_lambda decorator, @with_schema, and JobContext are the same building blocks either way — this section just reorders what matters first.

Why use ivcap-lambda for an MCP-only project?

Because its MCP support is built on the official mcp Python SDK rather than a custom protocol implementation, you get spec-correct session handling, both Streamable-HTTP and stdio transports, and progress notifications for free. On top, ivcap-lambda adds Pydantic-model-driven schemas, a thread pool so blocking tool code doesn't need to be rewritten as async def, and automatic bridging of jobCtxt.report.step(...) calls to native notifications/progress messages.

A minimal MCP-only service

from pydantic import BaseModel, Field
from ivcap_service import Service, with_schema
from ivcap_lambda import ivcap_lambda, start_lambda_server, logging_init

logging_init()

# Only `name` is surfaced to MCP clients (as the server's reported name) —
# keep Service minimal if you don't care about IVCAP registration.
service = Service(name="My MCP Server")


@with_schema("urn:sd:schema:my-mcp-server.add.request.1")
class AddRequest(BaseModel):
    a: int = Field(..., description="First number.")
    b: int = Field(..., description="Second number.")


@with_schema("urn:sd:schema:my-mcp-server.add.1")
class AddResult(BaseModel):
    sum: int = Field(..., description="a + b.")


@ivcap_lambda("/add")
def add(req: AddRequest) -> AddResult:
    """Add two numbers

    Returns the sum of the two provided numbers.
    """
    return AddResult(sum=req.a + req.b)


if __name__ == "__main__":
    start_lambda_server(service)

Run it purely as an MCP server over stdio (no HTTP port, no FastAPI, nothing IVCAP-specific) — ideal for Claude Desktop/Cline, which launch the server as a subprocess:

pip install ivcap-lambda[mcp]
python my_mcp_server.py --with-mcp-stdio

Or over HTTP for remote clients / the MCP Inspector:

python my_mcp_server.py --with-mcp --port 8090

What you can ignore (for now)

These only matter for IVCAP REST deployment and have no effect over MCP:

  • ToolOptions.max_wait_time / refresh_interval — MCP's tools/call always awaits the result directly; there is no REST-style "poll me later" (204) equivalent in the base MCP spec
  • ToolOptions.service_id — only populates the IVCAP tool-description endpoint
  • JobContext.ivcap / JobContext.job_authorization — only relevant if your tool talks to IVCAP artifacts/services
  • --print-tool-description / --print-service-description and [tool.poetry-plugin-ivcap] — only needed for IVCAP platform registration

What still matters

  • @with_schema — generates the MCP tool's input/output JSON schema from your Pydantic model; still required
  • Docstrings — the first paragraph becomes the MCP tool's short description surfaced to the model; the rest is the longer description
  • JobContext for progress — accept it as an optional parameter and use jobCtxt.report.step(...) for anything that takes more than a second or two, so MCP clients show live progress instead of appearing to hang (bounded by an internal 600s call timeout)
  • Raising ValueError for bad input — surfaces as an MCP tool error (is_error=True) the model can read and react to, instead of a crash
  • def or async def, your choice — a tool function can be written either way; ivcap-lambda detects which at registration time and runs it correctly either way (see Sync and async tool functions both work over MCP above)

Testing without a real transport

import asyncio
from ivcap_lambda.mcp import _build_mcp_server
import mcp

async def main():
    server = _build_mcp_server("My MCP Server", "0.1.0")
    async with mcp.Client(server) as client:          # in-memory, no network/subprocess
        result = await client.call_tool("add", {"req": {"a": 1, "b": 2}})
        print(result.structured_content)  # {'sum': 3}

asyncio.run(main())

See examples/test-mcp/ in the source repository for a complete runnable example covering both transports, including a plain-curl Streamable-HTTP test client. Its echo tool is defined with async def and demonstrates that async tool functions work identically to synchronous ones over MCP.

Deploying to IVCAP later

Nothing in your tool code needs to change — add ToolOptions(service_id=..., tags=[...]) and a [tool.poetry-plugin-ivcap] section to pyproject.toml, and follow the rest of this guide plus the Deployment Guide. MCP was never a fork of the REST path; it's an additional transport over the same tool registry.

Converting an Existing Plain mcp SDK Server

Already have a working MCP server built directly on the official mcp Python SDK (MCPServer/FastMCP, @mcp.tool()) and want to move it onto ivcap-lambda? This section covers migrating it so it keeps working exactly the same for MCP clients/hosts — same tool names, descriptions, and schemas — while also becoming a clean, first-class IVCAP REST service, with no further code changes needed when that's useful.

See also AGENTS.md's Track C: Converting an Existing Plain mcp SDK Server for the same material aimed at AI coding agents performing the migration.

What changes and what doesn't

Plain mcp SDK ivcap-lambda equivalent
mcp = MCPServer("Demo") (or FastMCP(...)) service = Service(name="Demo") — metadata only; tools register themselves via @ivcap_lambda, not by decorating a server instance
@mcp.tool() on a function with several plain-typed params @ivcap_lambda("/add") on a function taking one Pydantic request model as its first parameter
Plain/primitive return value A single @with_schema-decorated Pydantic result model
ctx: Context + await ctx.report_progress(...) jobCtxt: JobContext + with jobCtxt.report.step("name", "msg") as step: ... (no await needed)
mcp.run(transport="stdio") start_lambda_server(service) in code, --with-mcp-stdio on the command line
mcp.run(transport="streamable-http") start_lambda_server(service) in code, --with-mcp --port <port> on the command line
@mcp.resource(...) / @mcp.prompt(...) No equivalent — ivcap-lambda only exposes tools

The tool name, docstring (short/long description split), input/output JSON schema shape, progress notifications, and error behaviour (ValueError → is_error=True) are all preserved unchanged for MCP clients.

Example: before and after

The one required reshaping step is collecting a tool's separate scalar parameters into a single Pydantic request model, because @ivcap_lambda always passes a single parsed model as the first argument (the same model used for both the REST POST body and the MCP req argument):

Before: plain mcp SDK
from mcp.server import MCPServer

mcp = MCPServer("Demo")


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b


if __name__ == "__main__":
    mcp.run(transport="stdio")
After: ivcap-lambda
from pydantic import BaseModel, Field
from ivcap_service import Service, with_schema
from ivcap_lambda import ivcap_lambda, start_lambda_server, logging_init

logging_init()
service = Service(name="Demo")  # only `name` matters to MCP clients


@with_schema("urn:sd:schema:demo.add.request.1")
class AddRequest(BaseModel):
    a: int = Field(..., description="First number.")
    b: int = Field(..., description="Second number.")


@with_schema("urn:sd:schema:demo.add.1")
class AddResult(BaseModel):
    result: int = Field(..., description="a + b.")


@ivcap_lambda("/add")
def add(req: AddRequest) -> AddResult:
    """Add two numbers.

    Returns the sum of the two provided numbers.
    """
    return AddResult(result=req.a + req.b)


if __name__ == "__main__":
    start_lambda_server(service)

Run it the same way the old server was launched, just append the transport flag:

pip install ivcap-lambda[mcp]
python server.py --with-mcp-stdio          # stdio, unchanged for Claude Desktop/Cline
# or
python server.py --with-mcp --port 8090    # Streamable-HTTP

Existing Claude Desktop / Cline mcpServers config entries only need the args updated to include the new flag — the overall shape is unchanged:

{
  "mcpServers": {
    "demo": {
      "command": "python",
      "args": ["server.py", "--with-mcp-stdio"],
      "cwd": "/absolute/path/to/server"
    }
  }
}

Migrating progress reporting

Before
from mcp.server.mcpserver import Context

@mcp.tool()
async def long_task(n: int, ctx: Context) -> str:
    """Run a long task."""
    for i in range(n):
        await ctx.report_progress(i, total=n, message=f"step {i}")
    return "done"
After
@with_schema("urn:sd:schema:demo.long-task.request.1")
class LongTaskRequest(BaseModel):
    n: int = Field(..., description="Number of steps to run.")

@with_schema("urn:sd:schema:demo.long-task.1")
class LongTaskResult(BaseModel):
    status: str = Field(..., description="Final status.")

@ivcap_lambda("/long-task")
def long_task(req: LongTaskRequest, jobCtxt: JobContext) -> LongTaskResult:
    """Run a long task."""
    with jobCtxt.report.step("run", "running") as step:
        for i in range(req.n):
            step.info(f"step {i}")
        step.finished("done")
    return LongTaskResult(status="done")

async def is not required just to report progress — plain def tool functions report progress identically over MCP (see Sync and async tool functions both work over MCP above). Only use async def if the function body itself needs to await something.

Resources and prompts have no equivalent

ivcap-lambda only re-exposes @ivcap_lambda-registered functions as MCP tools — there is no equivalent for @mcp.resource(...) or @mcp.prompt(...). If the existing server relies on these, either keep them running on a small separate plain-mcp server alongside the migrated tools, or redesign them as tools (a read-only "resource" that just returns data can usually become a tool that returns a Pydantic model instead).

See Also