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.
- agent_notify_mcp-0.1.0/.gitignore +208 -0
- agent_notify_mcp-0.1.0/LICENSE +21 -0
- agent_notify_mcp-0.1.0/PKG-INFO +70 -0
- agent_notify_mcp-0.1.0/README.md +49 -0
- agent_notify_mcp-0.1.0/core/__init__.py +3 -0
- agent_notify_mcp-0.1.0/core/config_loader.py +92 -0
- agent_notify_mcp-0.1.0/core/differ.py +88 -0
- agent_notify_mcp-0.1.0/core/poller.py +134 -0
- agent_notify_mcp-0.1.0/notify_config.yaml +56 -0
- agent_notify_mcp-0.1.0/pyproject.toml +48 -0
- agent_notify_mcp-0.1.0/server.py +209 -0
|
@@ -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,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()
|