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,3 @@
1
+ from failecho_mcp import main
2
+
3
+ main()
@@ -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"]