failecho-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: failecho-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stdio MCP server that relays to the FailEcho network: check what other agents hit before retrying a failed tool.
|
|
5
|
+
Project-URL: Homepage, https://failecho.com
|
|
6
|
+
Project-URL: Documentation, https://failecho.com/setup
|
|
7
|
+
Project-URL: Source, https://github.com/FailEcho/failecho
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agents,mcp,model-context-protocol,reliability,retries
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: mcp>=2.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# failecho-mcp
|
|
15
|
+
|
|
16
|
+
Stdio MCP server that relays to [FailEcho](https://failecho.com): before your
|
|
17
|
+
agent retries a failed tool, check what other agents already tried and whether
|
|
18
|
+
it worked.
|
|
19
|
+
|
|
20
|
+
For hosts that can only start a local process. If your client speaks
|
|
21
|
+
Streamable HTTP, point it straight at `https://failecho.com/mcp` instead --
|
|
22
|
+
this package exists for the ones that cannot.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uvx failecho-mcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"failecho": { "command": "uvx", "args": ["failecho-mcp"] }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Four tools: `check_tool_failure` before a retry, and `report_tool_failure`,
|
|
37
|
+
`report_tool_success`, `report_recovery_outcome` to contribute. No account, no
|
|
38
|
+
API key. Set `FAILECHO_URL` to relay to your own server instead.
|
|
39
|
+
|
|
40
|
+
The relay stores nothing itself. MIT.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# failecho-mcp
|
|
2
|
+
|
|
3
|
+
Stdio MCP server that relays to [FailEcho](https://failecho.com): before your
|
|
4
|
+
agent retries a failed tool, check what other agents already tried and whether
|
|
5
|
+
it worked.
|
|
6
|
+
|
|
7
|
+
For hosts that can only start a local process. If your client speaks
|
|
8
|
+
Streamable HTTP, point it straight at `https://failecho.com/mcp` instead --
|
|
9
|
+
this package exists for the ones that cannot.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx failecho-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"failecho": { "command": "uvx", "args": ["failecho-mcp"] }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Four tools: `check_tool_failure` before a retry, and `report_tool_failure`,
|
|
24
|
+
`report_tool_success`, `report_recovery_outcome` to contribute. No account, no
|
|
25
|
+
API key. Set `FAILECHO_URL` to relay to your own server instead.
|
|
26
|
+
|
|
27
|
+
The relay stores nothing itself. MIT.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""FailEcho over stdio: a local MCP server that relays to the shared network.
|
|
2
|
+
|
|
3
|
+
Some MCP hosts can only start a local process and talk to it over
|
|
4
|
+
stdin/stdout -- Glama's hosting runner is one, and plenty of desktop clients
|
|
5
|
+
prefer it. FailEcho is one shared network at one URL, so this package does
|
|
6
|
+
not start a second FailEcho. It has no database and stores nothing. It relays
|
|
7
|
+
``tools/list`` and ``tools/call`` to the remote server and hands the answers
|
|
8
|
+
back unchanged, so a stdio client and an HTTP client see the same tools, the
|
|
9
|
+
same descriptions and the same evidence.
|
|
10
|
+
|
|
11
|
+
failecho-mcp # the public network
|
|
12
|
+
FAILECHO_URL=http://localhost:8000/mcp failecho-mcp # your own server
|
|
13
|
+
|
|
14
|
+
Kept apart from ``app`` on purpose: it imports only the MCP SDK and the
|
|
15
|
+
standard library, so running it never pulls in the server's database stack,
|
|
16
|
+
and it can move into its own distribution without edits.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import AsyncIterator
|
|
25
|
+
from contextlib import asynccontextmanager
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import anyio
|
|
29
|
+
from mcp import types
|
|
30
|
+
from mcp.client.session import ClientSession
|
|
31
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
32
|
+
from mcp.server.lowlevel import Server
|
|
33
|
+
from mcp.server.stdio import stdio_server
|
|
34
|
+
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
35
|
+
from mcp.shared.exceptions import MCPError
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
#: The shared network. A client's default has to be the real network: a relay
|
|
40
|
+
#: that defaulted to localhost would start an empty, private FailEcho in every
|
|
41
|
+
#: container, which is the one outcome this package exists to prevent.
|
|
42
|
+
#: Must match the remote declared in server.json -- a test holds them together.
|
|
43
|
+
DEFAULT_URL = "https://failecho.com/mcp"
|
|
44
|
+
|
|
45
|
+
#: Seconds to wait for the network while starting. Hosts give a server about
|
|
46
|
+
#: a minute to answer ``initialize``; this leaves most of that to spare.
|
|
47
|
+
STARTUP_TIMEOUT_SECONDS = 15.0
|
|
48
|
+
|
|
49
|
+
#: Label a demo agent sends so its traffic stays out of the adoption numbers.
|
|
50
|
+
#: Mirrors app.core.config.REPORTER_KIND_HEADER without importing the server.
|
|
51
|
+
REPORTER_KIND_HEADER = "X-Reporter-Kind"
|
|
52
|
+
|
|
53
|
+
#: Header proving a caller is one of FailEcho's own agents. Mirrors
|
|
54
|
+
#: app.core.config.OPERATOR_HEADER.
|
|
55
|
+
OPERATOR_HEADER = "X-FailEcho-Operator"
|
|
56
|
+
|
|
57
|
+
#: JSON-RPC "Internal error".
|
|
58
|
+
_INTERNAL_ERROR = -32603
|
|
59
|
+
|
|
60
|
+
#: Used only if the network was unreachable at startup. The live instructions
|
|
61
|
+
#: come from the server itself, so the two cannot drift while it is up.
|
|
62
|
+
FALLBACK_INSTRUCTIONS = (
|
|
63
|
+
"FailEcho is a shared failure-intelligence network for AI agents. When a "
|
|
64
|
+
"tool call fails, call check_tool_failure before retrying to see whether "
|
|
65
|
+
"other agents hit the same failure and which recovery worked for them. "
|
|
66
|
+
"Report failures, successes and recovery outcomes so the network stays "
|
|
67
|
+
"useful. Send failure metadata only -- never prompts, tool arguments, "
|
|
68
|
+
"tool results, request or response bodies, or credentials."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
log = logging.getLogger("failecho_mcp")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _describe(exc: BaseException) -> str:
|
|
75
|
+
"""The root cause, not the task-group wrapper around it.
|
|
76
|
+
|
|
77
|
+
Transport failures surface as "unhandled errors in a TaskGroup", which
|
|
78
|
+
tells an agent -- or whoever reads the log -- nothing about what broke.
|
|
79
|
+
"""
|
|
80
|
+
while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
|
|
81
|
+
exc = exc.exceptions[0]
|
|
82
|
+
return f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Relay:
|
|
86
|
+
"""Forwards MCP tool traffic from a local client to the FailEcho network."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
url: str = DEFAULT_URL,
|
|
91
|
+
reporter_kind: str | None = None,
|
|
92
|
+
operator_token: str | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.url = url
|
|
95
|
+
# Identifies relayed traffic in the server's logs, so adoption through
|
|
96
|
+
# this path can be counted honestly rather than guessed.
|
|
97
|
+
self.headers = {"User-Agent": f"failecho-mcp/{__version__}"}
|
|
98
|
+
if reporter_kind:
|
|
99
|
+
self.headers[REPORTER_KIND_HEADER] = reporter_kind
|
|
100
|
+
if operator_token:
|
|
101
|
+
self.headers[OPERATOR_HEADER] = operator_token
|
|
102
|
+
self._tools: list[types.Tool] | None = None
|
|
103
|
+
self._init: types.InitializeResult | None = None
|
|
104
|
+
|
|
105
|
+
@asynccontextmanager
|
|
106
|
+
async def _session(self) -> AsyncIterator[ClientSession]:
|
|
107
|
+
"""One short-lived upstream session per operation.
|
|
108
|
+
|
|
109
|
+
The remote server is stateless, so there is nothing to keep alive
|
|
110
|
+
between calls, and a fresh connection can never have gone stale.
|
|
111
|
+
"""
|
|
112
|
+
async with create_mcp_http_client(headers=self.headers) as http:
|
|
113
|
+
async with streamable_http_client(self.url, http_client=http) as (read, write):
|
|
114
|
+
async with ClientSession(read, write) as session:
|
|
115
|
+
self._init = await session.initialize()
|
|
116
|
+
yield session
|
|
117
|
+
|
|
118
|
+
async def _fetch_tools(self) -> list[types.Tool]:
|
|
119
|
+
async with self._session() as session:
|
|
120
|
+
result = await session.list_tools()
|
|
121
|
+
self._tools = list(result.tools)
|
|
122
|
+
return self._tools
|
|
123
|
+
|
|
124
|
+
async def warm(self, timeout: float = STARTUP_TIMEOUT_SECONDS) -> bool:
|
|
125
|
+
"""Fetch the network's instructions and tools before serving.
|
|
126
|
+
|
|
127
|
+
Failure is not fatal. The relay still starts and ``tools/list``
|
|
128
|
+
retries, so a host that cannot reach the network right now gets a
|
|
129
|
+
clear error on its first call instead of a server that never answers.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
with anyio.fail_after(timeout):
|
|
133
|
+
await self._fetch_tools()
|
|
134
|
+
return True
|
|
135
|
+
except Exception as exc: # noqa: BLE001 - any failure here means "offline"
|
|
136
|
+
log.warning("FailEcho network unreachable at %s: %s", self.url, _describe(exc))
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
async def list_tools(
|
|
140
|
+
self, ctx: Any, params: types.PaginatedRequestParams | None
|
|
141
|
+
) -> types.ListToolsResult:
|
|
142
|
+
if self._tools is None:
|
|
143
|
+
try:
|
|
144
|
+
await self._fetch_tools()
|
|
145
|
+
except Exception as exc: # noqa: BLE001
|
|
146
|
+
raise MCPError(
|
|
147
|
+
_INTERNAL_ERROR, f"FailEcho network unreachable at {self.url}: {_describe(exc)}"
|
|
148
|
+
) from exc
|
|
149
|
+
return types.ListToolsResult(tools=self._tools)
|
|
150
|
+
|
|
151
|
+
async def call_tool(
|
|
152
|
+
self, ctx: Any, params: types.CallToolRequestParams
|
|
153
|
+
) -> types.CallToolResult:
|
|
154
|
+
try:
|
|
155
|
+
async with self._session() as session:
|
|
156
|
+
return await session.call_tool(params.name, params.arguments or {})
|
|
157
|
+
except Exception as exc: # noqa: BLE001
|
|
158
|
+
# An agent calls FailEcho while it is already handling a failure.
|
|
159
|
+
# An unreachable network must not become a second one: say so
|
|
160
|
+
# plainly and let the agent fall back to its own retry policy.
|
|
161
|
+
log.warning("tool call %s failed to reach %s: %s", params.name, self.url, _describe(exc))
|
|
162
|
+
return types.CallToolResult(
|
|
163
|
+
content=[
|
|
164
|
+
types.TextContent(
|
|
165
|
+
type="text",
|
|
166
|
+
text=(
|
|
167
|
+
f"FailEcho network unreachable at {self.url}: {_describe(exc)}. "
|
|
168
|
+
"Nothing was recorded and no evidence was returned; "
|
|
169
|
+
"fall back to your own retry policy."
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
],
|
|
173
|
+
is_error=True,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def build_server(self) -> Server:
|
|
177
|
+
"""A local MCP server that presents itself exactly as the network does."""
|
|
178
|
+
info = self._init.server_info if self._init else None
|
|
179
|
+
return Server(
|
|
180
|
+
getattr(info, "name", None) or "failecho",
|
|
181
|
+
version=getattr(info, "version", None) or __version__,
|
|
182
|
+
title=getattr(info, "title", None) or "FailEcho",
|
|
183
|
+
instructions=(self._init.instructions if self._init else None)
|
|
184
|
+
or FALLBACK_INSTRUCTIONS,
|
|
185
|
+
website_url=getattr(info, "website_url", None),
|
|
186
|
+
on_list_tools=self.list_tools,
|
|
187
|
+
on_call_tool=self.call_tool,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def serve(
|
|
192
|
+
url: str = DEFAULT_URL,
|
|
193
|
+
reporter_kind: str | None = None,
|
|
194
|
+
operator_token: str | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
relay = Relay(url, reporter_kind, operator_token)
|
|
197
|
+
await relay.warm()
|
|
198
|
+
server = relay.build_server()
|
|
199
|
+
async with stdio_server() as (read, write):
|
|
200
|
+
await server.run(read, write, server.create_initialization_options())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def main() -> None:
|
|
204
|
+
# stdout carries the MCP protocol itself; every diagnostic goes to stderr.
|
|
205
|
+
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="failecho-mcp: %(message)s")
|
|
206
|
+
for noisy in ("httpx", "httpx2"):
|
|
207
|
+
logging.getLogger(noisy).setLevel(logging.WARNING)
|
|
208
|
+
|
|
209
|
+
url = os.environ.get("FAILECHO_URL") or DEFAULT_URL
|
|
210
|
+
reporter_kind = os.environ.get("FAILECHO_REPORTER_KIND") or None
|
|
211
|
+
# Only FailEcho's own agents have this; everyone else leaves it unset.
|
|
212
|
+
operator_token = os.environ.get("FAILECHO_OPERATOR_TOKEN") or None
|
|
213
|
+
log.info("relaying to %s", url)
|
|
214
|
+
try:
|
|
215
|
+
anyio.run(serve, url, reporter_kind, operator_token)
|
|
216
|
+
except KeyboardInterrupt:
|
|
217
|
+
pass
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "failecho-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stdio MCP server that relays to the FailEcho network: check what other agents hit before retrying a failed tool."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["mcp", "model-context-protocol", "agents", "reliability", "retries"]
|
|
13
|
+
dependencies = ["mcp>=2.0"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://failecho.com"
|
|
17
|
+
Documentation = "https://failecho.com/setup"
|
|
18
|
+
Source = "https://github.com/FailEcho/failecho"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
failecho-mcp = "failecho_mcp:main"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["failecho_mcp"]
|