Skip to content

Host 如何把 MCP 接进模型工具循环 ​

从发现目录到平台结果 ​

沿用MCP 1.12.4环境并安装jsonschema==4.26.0。两个独立Server提供同名search;Host为模型生成唯一名称,注册表保存名称到真实session的映射。以下只用固定模型意图隔离宿主逻辑,真实MCP初始化、目录、工具调用与错误返回均运行。不会调用云模型。

保存host_server.py。crash_for_test只用于故障实验,会退出自己的临时Server进程;Host不会把它放进模型可见目录。

python
import os
from mcp.server.fastmcp import FastMCP
mcp=FastMCP('scope lab')
@mcp.tool()
def search(query:str)->str:
    if query not in {'public','private'}:raise ValueError('unknown query')
    if query=='private':raise PermissionError('service policy denies private data')
    return os.environ.get('LAB_SOURCE','docs')+': public evidence'
@mcp.tool()
def crash_for_test()->str:
    """Local fault injection only; terminate this disposable test server."""
    os._exit(7)
if __name__=='__main__':mcp.run(transport='stdio')

同目录保存host.py,执行python host.py:

python
import asyncio,json,sys
from contextlib import AsyncExitStack
from mcp import ClientSession,StdioServerParameters
from mcp.client.stdio import stdio_client
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError
async def open_server(stack,alias):
    read,write=await stack.enter_async_context(stdio_client(StdioServerParameters(
        command=sys.executable,args=['host_server.py'],env={'LAB_SOURCE':alias})))
    session=await stack.enter_async_context(ClientSession(read,write))
    await session.initialize()
    return session
async def run():
    async with AsyncExitStack() as stack:
        sessions={a:await open_server(stack,a) for a in ('docs','tickets')}
        registry={};declarations=[]
        for alias,session in sessions.items():
            cursor=None
            while True:
                page=await session.list_tools(cursor=cursor)
                for tool in page.tools:
                    if tool.name!='search':continue # test-only crash is not exposed to model
                    name=alias+'__'+tool.name
                    registry[name]=(session,tool)
                    declarations.append({'type':'function','name':name,'description':tool.description or '',
                        'parameters':tool.inputSchema})
                cursor=page.nextCursor
                if not cursor:break
        assert {d['name'] for d in declarations}=={'docs__search','tickets__search'}
        executed=[]
        async def execute(intent,actor):
            name=intent['name'];session,tool=registry[name]
            args=json.loads(intent['arguments'])
            Draft202012Validator(tool.inputSchema).validate(args)
            # actor is authenticated host context, not part of model arguments.
            if actor=='reader' and name.startswith('tickets__'):raise PermissionError('host scope')
            result=await session.call_tool(tool.name,args)
            executed.append(name)
            data={'ok':not result.isError,'content':[x.model_dump() for x in result.content]}
            return {'type':'function_call_output','call_id':intent['call_id'],'output':json.dumps(data)}
        intent={'call_id':'call-1','name':'docs__search','arguments':'{"query":"public"}'}
        output=await execute(intent,'reader')
        assert output['call_id']=='call-1' and json.loads(output['output'])['ok']
        # Deterministic model adapter consumes the actual protocol result; no paid model call.
        final='answer with evidence' if json.loads(output['output'])['ok'] else 'cannot answer'
        assert final=='answer with evidence'
        for bad in ({**intent,'arguments':'{"query":3}'},{**intent,'name':'tickets__search'}):
            try:await execute(bad,'reader')
            except (ValueError,PermissionError,ValidationError):pass
            else:raise AssertionError('invalid intention accepted')
        assert executed==['docs__search']
        failed=await execute({**intent,'call_id':'call-2','arguments':'{"query":"private"}'},'reader')
        assert not json.loads(failed['output'])['ok']
        print('discovery, schema, scope, service failure and correlated result passed')
async def reconnect():
    try:
        async with AsyncExitStack() as stack:
            session=await open_server(stack,'docs')
            await session.call_tool('crash_for_test',{})
    except Exception:pass # transport closes; new session must prove recovery
    else:raise AssertionError('crash should close transport')
    async with AsyncExitStack() as stack:
        session=await open_server(stack,'docs')
        result=await session.call_tool('search',{'query':'public'})
        assert not result.isError
    print('crashed process replaced and session initialized again')
asyncio.run(run())
asyncio.run(reconnect())

实际过程是list_tools分页→平台function声明→固定模型意图→schema与身份校验→session.call_tool→平台function_call_output→下一轮回答。function_call_output使用模型call_id,MCP请求ID由ClientSession内部维护;不能把两种身份互换。伪造参数类型或跨scope不会触发服务函数;已授权调用也可能被Server业务政策拒绝,此时isError=True需要进入失败观察。

进程退出后正在等待的RPC抛错,Host建立新进程并重新initialize才能查询。这里只对只读查询重建会话;写调用中断时不能立即重放,因为外部副作用可能已发生。重连不是恢复旧会话,也不保证重放旧事件;业务幂等与任务查询另行实现。

练习 ​

给reader增加tickets只读授权,并保留对private查询的服务端拒绝。证明两个同名search实际返回不同来源,模型call_id分别保留;给不存在的映射名输入应明确拒绝而不能落到任意默认Server。

本章答案

用心记录每一步成长