agent-notify-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,208 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+ notify_config_sample.yaml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tharindu Mendis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-notify-mcp
3
+ Version: 0.1.0
4
+ Summary: Universal MCP notification relay — polls MCP servers and streams change events as log notifications
5
+ Project-URL: Homepage, https://github.com/tharindumendis/agent-notify
6
+ Project-URL: Source, https://github.com/tharindumendis/agent-notify
7
+ Project-URL: Tracker, https://github.com/tharindumendis/agent-notify/issues
8
+ Author: Tharindu Mendis
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,mcp,notification,polling,telegram
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+
22
+ # agent-notify
23
+
24
+ Universal MCP notification relay.
25
+
26
+ Agent_notify polls one or more MCP servers for configured tools and streams any changes as JSON notifications via MCP log events.
27
+
28
+ ## Install (using `uv`)
29
+
30
+ This repository is designed to work with `uv` (a thin wrapper around `python`/`pip` used throughout this workspace).
31
+
32
+ ```sh
33
+ cd Agent_notify
34
+ uv venv .venv # creates a virtualenv in .venv
35
+ .venv\Scripts\activate # Windows (use `source .venv/bin/activate` on macOS/Linux)
36
+ uv sync # install dependencies from pyproject.toml
37
+ ```
38
+
39
+ ## Quickstart (using `uv run`)
40
+
41
+ 1. Create or edit `notify_config.yaml` (a default example is provided in the repository).
42
+ 2. Run the agent:
43
+
44
+ ```sh
45
+ uv run agent-notify
46
+ ```
47
+
48
+ You can override the config path:
49
+
50
+ ```sh
51
+ AGENT_NOTIFY_CONFIG=/path/to/notify_config.yaml uv run agent-notify
52
+ ```
53
+
54
+ > If you prefer, you can still install from PyPI:
55
+ >
56
+ > ```sh
57
+ > pip install agent-notify
58
+ > agent-notify
59
+ > ```
60
+
61
+ ## How it works
62
+
63
+ - Polls every configured server/tool at `poll_interval` seconds.
64
+ - When a tool's returned value changes between polls, it emits a JSON notification.
65
+ - Notifications are streamed as log events (MCP `ctx.info`) until the client disconnects.
66
+
67
+ ## Usage notes
68
+
69
+ - Enable debug logging by setting `debug: true` in `notify_config.yaml`.
70
+ - Logs are written to stderr and optionally to `log_file` when configured.
@@ -0,0 +1,49 @@
1
+ # agent-notify
2
+
3
+ Universal MCP notification relay.
4
+
5
+ Agent_notify polls one or more MCP servers for configured tools and streams any changes as JSON notifications via MCP log events.
6
+
7
+ ## Install (using `uv`)
8
+
9
+ This repository is designed to work with `uv` (a thin wrapper around `python`/`pip` used throughout this workspace).
10
+
11
+ ```sh
12
+ cd Agent_notify
13
+ uv venv .venv # creates a virtualenv in .venv
14
+ .venv\Scripts\activate # Windows (use `source .venv/bin/activate` on macOS/Linux)
15
+ uv sync # install dependencies from pyproject.toml
16
+ ```
17
+
18
+ ## Quickstart (using `uv run`)
19
+
20
+ 1. Create or edit `notify_config.yaml` (a default example is provided in the repository).
21
+ 2. Run the agent:
22
+
23
+ ```sh
24
+ uv run agent-notify
25
+ ```
26
+
27
+ You can override the config path:
28
+
29
+ ```sh
30
+ AGENT_NOTIFY_CONFIG=/path/to/notify_config.yaml uv run agent-notify
31
+ ```
32
+
33
+ > If you prefer, you can still install from PyPI:
34
+ >
35
+ > ```sh
36
+ > pip install agent-notify
37
+ > agent-notify
38
+ > ```
39
+
40
+ ## How it works
41
+
42
+ - Polls every configured server/tool at `poll_interval` seconds.
43
+ - When a tool's returned value changes between polls, it emits a JSON notification.
44
+ - Notifications are streamed as log events (MCP `ctx.info`) until the client disconnects.
45
+
46
+ ## Usage notes
47
+
48
+ - Enable debug logging by setting `debug: true` in `notify_config.yaml`.
49
+ - Logs are written to stderr and optionally to `log_file` when configured.
@@ -0,0 +1,3 @@
1
+ """
2
+ core/__init__.py
3
+ """
@@ -0,0 +1,92 @@
1
+ """
2
+ core/config_loader.py
3
+ ----------------------
4
+ Loads and parses notify_config.yaml.
5
+
6
+ Search order for config file:
7
+ 1. AGENT_NOTIFY_CONFIG environment variable
8
+ 2. ./notify_config.yaml (current working directory)
9
+ 3. ~/.config/agent-notify/config.yaml
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+
21
+
22
+ @dataclass
23
+ class ToolPollConfig:
24
+ """One tool to poll on a server."""
25
+ name: str
26
+ args: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ @dataclass
30
+ class ServerPollConfig:
31
+ """One MCP server to connect to and poll."""
32
+ name: str
33
+ command: str
34
+ args: list[str] = field(default_factory=list)
35
+ env: dict[str, str] = field(default_factory=dict)
36
+ tools: list[ToolPollConfig] = field(default_factory=list)
37
+
38
+
39
+ @dataclass
40
+ class NotifyConfig:
41
+ poll_interval: int # seconds between each poll cycle
42
+ servers: list[ServerPollConfig]
43
+ debug: bool = False # log every poll cycle
44
+ log_file: str | None = None # path to log file (None = stderr only)
45
+
46
+
47
+ def load_config(path: str | None = None) -> NotifyConfig:
48
+ """Load notify_config.yaml from *path* or from the default search order."""
49
+ if path is None:
50
+ path = os.environ.get("AGENT_NOTIFY_CONFIG")
51
+
52
+ if path is None:
53
+ candidates = [
54
+ Path("notify_config.yaml"),
55
+ Path.home() / ".config" / "agent-notify" / "config.yaml",
56
+ ]
57
+ for c in candidates:
58
+ if c.exists():
59
+ path = c
60
+ break
61
+
62
+ if path is None:
63
+ raise FileNotFoundError(
64
+ "notify_config.yaml not found. "
65
+ "Create one in the current directory or set AGENT_NOTIFY_CONFIG."
66
+ )
67
+
68
+ with open(path, encoding="utf-8") as f:
69
+ data = yaml.safe_load(f)
70
+
71
+ servers: list[ServerPollConfig] = []
72
+ for s in data.get("servers", []):
73
+ tools = [
74
+ ToolPollConfig(name=t["tool"], args=t.get("args", {}))
75
+ for t in s.get("tools", [])
76
+ ]
77
+ servers.append(
78
+ ServerPollConfig(
79
+ name=s["name"],
80
+ command=s["command"],
81
+ args=s.get("args", []),
82
+ env=s.get("env", {}),
83
+ tools=tools,
84
+ )
85
+ )
86
+
87
+ return NotifyConfig(
88
+ poll_interval=int(data.get("poll_interval", 30)),
89
+ servers=servers,
90
+ debug=bool(data.get("debug", False)),
91
+ log_file=data.get("log_file", None),
92
+ )
@@ -0,0 +1,88 @@
1
+ """
2
+ core/differ.py
3
+ --------------
4
+ Compares old vs new MCP tool responses and returns a change summary,
5
+ or None if nothing changed.
6
+
7
+ Handles:
8
+ - JSON arrays → finds added / removed items by stable ID
9
+ - JSON objects → reports key-level diff
10
+ - scalars → reports old vs new value
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from typing import Any
18
+
19
+
20
+ def _stable_id(item: Any) -> str:
21
+ """Extract or derive a stable string ID from an item."""
22
+ if isinstance(item, dict):
23
+ for key in ("id", "message_id", "msgId", "uid", "number",
24
+ "pr_number", "sha", "iid", "key", "name"):
25
+ if key in item:
26
+ return str(item[key])
27
+ return hashlib.md5(
28
+ json.dumps(item, sort_keys=True, default=str).encode()
29
+ ).hexdigest()
30
+
31
+
32
+ def diff_results(old_raw: str | None, new_raw: str | None) -> dict | None:
33
+ """
34
+ Compare *old_raw* and *new_raw* (JSON strings from MCP tool responses).
35
+
36
+ Returns a change dict, or None if there is no meaningful change.
37
+
38
+ Change dict shape:
39
+ { "added": [...] } – new list items
40
+ { "removed": [...] } – removed list items
41
+ { "added": [...], "removed": [...] }
42
+ { "changed": {"from": ..., "to": ...} } – scalar / dict change
43
+ """
44
+ if old_raw == new_raw:
45
+ return None
46
+
47
+ try:
48
+ old = json.loads(old_raw) if isinstance(old_raw, str) else old_raw
49
+ new = json.loads(new_raw) if isinstance(new_raw, str) else new_raw
50
+
51
+ # ── List diff ─────────────────────────────────────────────────────────
52
+ if isinstance(old, list) and isinstance(new, list):
53
+ old_map = {_stable_id(i): i for i in old}
54
+ new_map = {_stable_id(i): i for i in new}
55
+
56
+ added = [i for k, i in new_map.items() if k not in old_map]
57
+ removed = [i for k, i in old_map.items() if k not in new_map]
58
+
59
+ changes: dict = {}
60
+ if added:
61
+ changes["added"] = added
62
+ if removed:
63
+ changes["removed"] = removed
64
+ return changes or None
65
+
66
+ # ── Dict diff ────────────────────────────────────────────────────────
67
+ if isinstance(old, dict) and isinstance(new, dict):
68
+ all_keys = set(old) | set(new)
69
+ changed_keys = {k for k in all_keys if old.get(k) != new.get(k)}
70
+ if not changed_keys:
71
+ return None
72
+ return {
73
+ "changed": {
74
+ k: {"from": old.get(k), "to": new.get(k)}
75
+ for k in changed_keys
76
+ }
77
+ }
78
+
79
+ # ── Scalar ───────────────────────────────────────────────────────────
80
+ if old != new:
81
+ return {"changed": {"from": old, "to": new}}
82
+
83
+ return None
84
+
85
+ except (json.JSONDecodeError, TypeError):
86
+ if old_raw != new_raw:
87
+ return {"changed": {"from": old_raw, "to": new_raw}}
88
+ return None
@@ -0,0 +1,134 @@
1
+ """
2
+ core/poller.py
3
+ --------------
4
+ Connects to all configured MCP servers as a CLIENT, then polls each
5
+ server's configured tools on every tick, diffing results to detect changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from contextlib import AsyncExitStack
13
+ from typing import Any
14
+
15
+ from mcp import ClientSession, StdioServerParameters
16
+ from mcp.client.stdio import stdio_client
17
+
18
+ from core.config_loader import NotifyConfig
19
+ from core.differ import diff_results
20
+
21
+
22
+ class Poller:
23
+ """
24
+ Manages persistent MCP client connections to all configured servers
25
+ and polls their tools on each call to poll_all().
26
+ """
27
+
28
+ def __init__(self, config: NotifyConfig) -> None:
29
+ self.config = config
30
+ self._sessions: dict[str, ClientSession] = {}
31
+ self._last_results: dict[str, str | None] = {}
32
+ self._stack = AsyncExitStack()
33
+
34
+ # ── Context manager ───────────────────────────────────────────────────────
35
+
36
+ async def __aenter__(self) -> "Poller":
37
+ await self._stack.__aenter__()
38
+ for server in self.config.servers:
39
+ try:
40
+ env = {**os.environ, **server.env}
41
+ params = StdioServerParameters(
42
+ command=server.command,
43
+ args=server.args,
44
+ env=env,
45
+ )
46
+ transport = await self._stack.enter_async_context(
47
+ stdio_client(params)
48
+ )
49
+ read, write = transport
50
+ session = await self._stack.enter_async_context(
51
+ ClientSession(read, write)
52
+ )
53
+ await session.initialize()
54
+ self._sessions[server.name] = session
55
+ except Exception as exc:
56
+ # Partial failure — log to stderr, continue with other servers
57
+ import sys
58
+ print(
59
+ f"[agent-notify] WARNING: could not connect to '{server.name}': {exc}",
60
+ file=sys.stderr,
61
+ )
62
+ return self
63
+
64
+ async def __aexit__(self, *args: Any) -> None:
65
+ await self._stack.__aexit__(*args)
66
+
67
+ # ── Polling ───────────────────────────────────────────────────────────────
68
+
69
+ async def poll_all(self, first_poll: bool = False) -> list[dict]:
70
+ """
71
+ Call every configured tool on every connected server.
72
+ Compare results to the previous poll and return a list of change events.
73
+
74
+ On *first_poll* we only baseline the results (no changes emitted).
75
+ """
76
+ events: list[dict] = []
77
+
78
+ for server in self.config.servers:
79
+ session = self._sessions.get(server.name)
80
+ if session is None:
81
+ continue
82
+
83
+ for tool_cfg in server.tools:
84
+ key = f"{server.name}::{tool_cfg.name}"
85
+
86
+ try:
87
+ result = await asyncio.wait_for(
88
+ session.call_tool(tool_cfg.name, arguments=tool_cfg.args),
89
+ timeout=30.0,
90
+ )
91
+
92
+ # Extract the text payload from the MCP result
93
+ new_data: str | None = None
94
+ for content in result.content or []:
95
+ if hasattr(content, "text") and content.text:
96
+ new_data = content.text
97
+ break
98
+
99
+ old_data = self._last_results.get(key)
100
+ self._last_results[key] = new_data
101
+
102
+ # First poll: baseline only — emit nothing
103
+ if first_poll:
104
+ continue
105
+
106
+ # Skip if we have nothing to compare yet
107
+ if old_data is None or new_data is None:
108
+ continue
109
+
110
+ change = diff_results(old_data, new_data)
111
+ if change:
112
+ events.append({
113
+ "server": server.name,
114
+ "tool": tool_cfg.name,
115
+ "change": change,
116
+ })
117
+
118
+ except asyncio.TimeoutError:
119
+ if not first_poll:
120
+ events.append({
121
+ "server": server.name,
122
+ "tool": tool_cfg.name,
123
+ "error": "Tool call timed out (30 s)",
124
+ })
125
+
126
+ except Exception as exc:
127
+ if not first_poll:
128
+ events.append({
129
+ "server": server.name,
130
+ "tool": tool_cfg.name,
131
+ "error": str(exc),
132
+ })
133
+
134
+ return events
@@ -0,0 +1,56 @@
1
+ # ─────────────────────────────────────────────────────────────────
2
+ # Agent_notify — notify_config.yaml
3
+ # ─────────────────────────────────────────────────────────────────
4
+ #
5
+ # This file tells Agent_notify which MCP servers to connect to
6
+ # and which tools to poll. Any change in a tool's response between
7
+ # polls is emitted as a JSON notification to the subscriber.
8
+ #
9
+ # EACH server entry uses the SAME format as MCP client config —
10
+ # command + args + env, exactly as you'd put in claude_desktop or
11
+ # Agent_head's config.yaml.
12
+ # ─────────────────────────────────────────────────────────────────
13
+
14
+ # How often to poll every server/tool (in seconds).
15
+ poll_interval: 15
16
+
17
+ # Debug mode: logs every poll cycle to a file.
18
+ debug: true
19
+ log_file: "D:\\DEV\\mcp\\universai\\orchestra\\Agent_notify\\agent_notify.log"
20
+
21
+ servers:
22
+ # ── Telegram ──────────────────────────────────────────────────
23
+ - name: telegram
24
+ command: npx
25
+ args: ["-y", "@tharindumendis100/tgcli", "mcp", "--transport", "stdio"]
26
+ env:
27
+ TELEGRAM_API_ID: "your_telegram_api_id"
28
+ TELEGRAM_API_HASH: "your_telegram_api_hash"
29
+ tools:
30
+ # Detect new incoming messages in your personal inbox
31
+ - tool: listActiveChannels
32
+ args: {}
33
+
34
+ # Detect new messages in a specific group/channel (optional)
35
+ - tool: messagesList
36
+ args:
37
+ channelId: "1173401646"
38
+ limit: 5
39
+
40
+ # ── GitHub (optional) ─────────────────────────────────────────
41
+ # - name: github
42
+ # command: npx
43
+ # args: ["-y", "@modelcontextprotocol/server-github"]
44
+ # env:
45
+ # GITHUB_PERSONAL_ACCESS_TOKEN: "your_github_token"
46
+ # tools:
47
+ # # Detect new issues opened in your repo
48
+ # - tool: list_issues
49
+ # args:
50
+ # owner: "your_github_username"
51
+ # repo: "your_repo_name"
52
+ # state: "open"
53
+
54
+ # ── Any other MCP server ──────────────────────────────────────
55
+ # Add more servers the same way. Any MCP tool that returns a
56
+ # list or a value can be monitored for changes.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agent-notify-mcp"
7
+ version = "0.1.0"
8
+ description = "Universal MCP notification relay — polls MCP servers and streams change events as log notifications"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tharindu Mendis" }]
13
+ keywords = ["mcp", "notification", "agent", "telegram", "polling"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "License :: OSI Approved :: MIT License",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/tharindumendis/agent-notify"
26
+ Source = "https://github.com/tharindumendis/agent-notify"
27
+ Tracker = "https://github.com/tharindumendis/agent-notify/issues"
28
+
29
+ [project.scripts]
30
+ agent-notify = "server:main"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["core"]
34
+
35
+ [tool.hatch.build.targets.wheel.force-include]
36
+ "server.py" = "server.py"
37
+ "notify_config.yaml" = "notify_config.yaml"
38
+ "LICENSE" = "LICENSE"
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = [
42
+ "core/",
43
+ "server.py",
44
+ "notify_config.yaml",
45
+ "README.md",
46
+ "LICENSE",
47
+ "pyproject.toml",
48
+ ]
@@ -0,0 +1,209 @@
1
+ """
2
+ server.py — Agent_notify MCP Server
3
+ --------------------------------------
4
+ A FastMCP server that exposes a single long-running tool: get_notifications().
5
+
6
+ When called, it:
7
+ 1. Connects to all MCP servers listed in notify_config.yaml
8
+ 2. Polls configured tools at poll_interval seconds
9
+ 3. Diffs each result against the previous poll
10
+ 4. Sends every change as a ctx.info(json) log notification WITHOUT
11
+ closing the tool call — the client receives a stream of events
12
+
13
+ Debug mode (debug: true in notify_config.yaml):
14
+ - Logs every poll cycle to the configured log_file
15
+ - Format: timestamped lines with server/tool/status/changes
16
+
17
+ Usage:
18
+ agent-notify # uses notify_config.yaml in cwd
19
+ AGENT_NOTIFY_CONFIG=/path/to/cfg.yaml agent-notify
20
+ uvx agent-notify
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ import sys
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+
32
+ from mcp.server.fastmcp import FastMCP, Context
33
+
34
+ from core.config_loader import NotifyConfig, load_config
35
+ from core.poller import Poller
36
+
37
+ mcp = FastMCP(
38
+ "agent-notify",
39
+ instructions=(
40
+ "Universal MCP notification relay. "
41
+ "Call get_notifications() to subscribe to real-time change events "
42
+ "from all configured MCP servers. "
43
+ "Events are streamed as log notifications (ctx.info) — "
44
+ "the tool call does not return until the client disconnects."
45
+ ),
46
+ )
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Debug logger
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _make_logger(config: NotifyConfig) -> logging.Logger:
54
+ """Set up a file logger when debug=true, else a null logger."""
55
+ log = logging.getLogger("agent_notify")
56
+ log.setLevel(logging.DEBUG if config.debug else logging.WARNING)
57
+ log.handlers.clear()
58
+
59
+ if config.debug:
60
+ fmt = logging.Formatter("%(asctime)s | %(levelname)-5s | %(message)s",
61
+ datefmt="%Y-%m-%d %H:%M:%S")
62
+
63
+ # Always write to stderr
64
+ sh = logging.StreamHandler(sys.stderr)
65
+ sh.setFormatter(fmt)
66
+ log.addHandler(sh)
67
+
68
+ # Optionally also write to a file
69
+ if config.log_file:
70
+ log_path = Path(config.log_file)
71
+ log_path.parent.mkdir(parents=True, exist_ok=True)
72
+ fh = logging.FileHandler(log_path, encoding="utf-8")
73
+ fh.setFormatter(fmt)
74
+ log.addHandler(fh)
75
+
76
+ return log
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Tool
81
+ # ---------------------------------------------------------------------------
82
+
83
+ @mcp.tool()
84
+ async def get_notifications(ctx: Context) -> str:
85
+ """
86
+ Subscribe to real-time change notifications from configured MCP servers.
87
+
88
+ This tool NEVER returns normally — it streams JSON change events as
89
+ log notifications (ctx.info) until the client disconnects or cancels.
90
+
91
+ Each notification is a JSON object:
92
+ {
93
+ "server": "<server_name>",
94
+ "tool": "<tool_name>",
95
+ "change": {
96
+ "added": [...], // new items in a list result
97
+ "removed": [...], // removed items
98
+ // OR
99
+ "changed": { "from": ..., "to": ... }
100
+ }
101
+ }
102
+
103
+ On error polling a tool:
104
+ { "server": "...", "tool": "...", "error": "reason" }
105
+ """
106
+ # Load config fresh on each call (supports hot-reload)
107
+ try:
108
+ config = load_config()
109
+ except FileNotFoundError as exc:
110
+ await ctx.error(str(exc))
111
+ return f"ERROR: {exc}"
112
+
113
+ log = _make_logger(config)
114
+
115
+ n_servers = len(config.servers)
116
+ n_tools = sum(len(s.tools) for s in config.servers)
117
+
118
+ log.info("Agent Notify starting | servers=%d tools=%d interval=%ds debug=%s log=%s",
119
+ n_servers, n_tools, config.poll_interval, config.debug, config.log_file or "stderr")
120
+
121
+ started_msg = {
122
+ "type": "started",
123
+ "servers": n_servers,
124
+ "tools": n_tools,
125
+ "interval_seconds": config.poll_interval,
126
+ "debug": config.debug,
127
+ "log_file": config.log_file,
128
+ "message": (
129
+ f"Agent Notify: monitoring {n_tools} tool(s) across "
130
+ f"{n_servers} server(s) every {config.poll_interval}s"
131
+ ),
132
+ }
133
+ await ctx.info(json.dumps(started_msg))
134
+
135
+ try:
136
+ async with Poller(config) as poller:
137
+ cycle = 0
138
+ first = True
139
+
140
+ while True:
141
+ cycle += 1
142
+ cycle_start = datetime.now()
143
+
144
+ log.debug("── Poll cycle #%d started at %s", cycle, cycle_start.strftime("%H:%M:%S"))
145
+
146
+ try:
147
+ events = await poller.poll_all(first_poll=first)
148
+ first = False
149
+
150
+ cycle_ms = int((datetime.now() - cycle_start).total_seconds() * 1000)
151
+
152
+ if config.debug:
153
+ # Log summary for every server/tool polled
154
+ for server in config.servers:
155
+ for tool_cfg in server.tools:
156
+ key = f"{server.name}/{tool_cfg.name}"
157
+ # Find matching event if any
158
+ match = next(
159
+ (e for e in events
160
+ if e.get("server") == server.name
161
+ and e.get("tool") == tool_cfg.name),
162
+ None
163
+ )
164
+ if match and "error" in match:
165
+ log.warning(" [%s] ERROR: %s", key, match["error"])
166
+ elif match:
167
+ change = match.get("change", {})
168
+ added = len(change.get("added", []))
169
+ removed = len(change.get("removed", []))
170
+ log.debug(" [%s] CHANGED | +%d -%d items", key, added, removed)
171
+ else:
172
+ log.debug(" [%s] no change", key)
173
+
174
+ log.debug("── Cycle #%d done in %dms | %d change(s)",
175
+ cycle, cycle_ms, len(events))
176
+
177
+ # Emit events to client
178
+ for event in events:
179
+ log.info("NOTIFY → %s", json.dumps(event))
180
+ await ctx.info(json.dumps(event))
181
+
182
+ except Exception as poll_exc:
183
+ log.error("Poll cycle #%d failed: %s", cycle, poll_exc, exc_info=True)
184
+ await ctx.warning(json.dumps({
185
+ "type": "poll_cycle_error",
186
+ "cycle": cycle,
187
+ "error": str(poll_exc),
188
+ }))
189
+
190
+ log.debug("Sleeping %ds until cycle #%d …", config.poll_interval, cycle + 1)
191
+ await asyncio.sleep(config.poll_interval)
192
+
193
+ except asyncio.CancelledError:
194
+ log.info("Agent Notify: subscription cancelled by client.")
195
+
196
+ return "Agent Notify: subscription ended."
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Entry point
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def main() -> None:
204
+ """Entry point for `agent-notify` CLI command."""
205
+ mcp.run(transport="stdio")
206
+
207
+
208
+ if __name__ == "__main__":
209
+ main()