armature-mcp-analytics 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,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ push:
8
+ branches:
9
+ - main
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ checks:
16
+ name: Checks
17
+ runs-on: ubuntu-latest
18
+ timeout-minutes: 10
19
+ permissions:
20
+ contents: read
21
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
22
+
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ python-version: ["3.10", "3.11", "3.12"]
27
+
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v4
31
+
32
+ - name: Setup Python
33
+ uses: actions/setup-python@v5
34
+ with:
35
+ python-version: ${{ matrix.python-version }}
36
+
37
+ - name: Run checks
38
+ run: PYTHONPATH=src python -m unittest discover -s tests
39
+
40
+ - name: Build package
41
+ run: |
42
+ python -m pip install --upgrade build
43
+ python -m build
44
+
@@ -0,0 +1,140 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+ id-token: write
11
+
12
+ concurrency:
13
+ group: publish-pypi
14
+ cancel-in-progress: false
15
+
16
+ jobs:
17
+ publish:
18
+ name: Publish to PyPI
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 15
21
+ if: github.repository == 'armature-tech/mcp-analytics-python'
22
+
23
+ steps:
24
+ - name: Checkout
25
+ uses: actions/checkout@v4
26
+ with:
27
+ fetch-depth: 0
28
+
29
+ - name: Check whether this commit is already released
30
+ id: existing_release
31
+ run: |
32
+ set -euo pipefail
33
+ tag="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -1 || true)"
34
+ if [[ -n "${tag}" ]]; then
35
+ echo "Commit ${GITHUB_SHA} already has release tag ${tag}; skipping publish."
36
+ echo "published=true" >> "${GITHUB_OUTPUT}"
37
+ else
38
+ echo "published=false" >> "${GITHUB_OUTPUT}"
39
+ fi
40
+
41
+ - name: Setup Python
42
+ if: steps.existing_release.outputs.published != 'true'
43
+ uses: actions/setup-python@v5
44
+ with:
45
+ python-version: "3.12"
46
+
47
+ - name: Run checks
48
+ if: steps.existing_release.outputs.published != 'true'
49
+ run: PYTHONPATH=src python -m unittest discover -s tests
50
+
51
+ - name: Resolve next patch version
52
+ if: steps.existing_release.outputs.published != 'true'
53
+ id: version
54
+ env:
55
+ INITIAL_VERSION: "0.1.0"
56
+ run: |
57
+ set -euo pipefail
58
+ python <<'PY'
59
+ import json
60
+ import os
61
+ import re
62
+ import subprocess
63
+ import sys
64
+ import urllib.error
65
+ import urllib.request
66
+ from pathlib import Path
67
+
68
+ pyproject_path = Path("pyproject.toml")
69
+ text = pyproject_path.read_text()
70
+ name_match = re.search(r'^name = "([^"]+)"$', text, re.MULTILINE)
71
+ if not name_match:
72
+ raise SystemExit("Could not find project name in pyproject.toml")
73
+ package_name = name_match.group(1)
74
+
75
+ versions = []
76
+
77
+ def add_version(candidate):
78
+ if not isinstance(candidate, str):
79
+ return
80
+ normalized = candidate.strip().removeprefix("v")
81
+ if re.fullmatch(r"\d+\.\d+\.\d+", normalized):
82
+ versions.append(normalized)
83
+
84
+ try:
85
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{package_name}/json", timeout=15) as response:
86
+ data = json.load(response)
87
+ for version in data.get("releases", {}):
88
+ add_version(version)
89
+ except urllib.error.HTTPError as error:
90
+ if error.code != 404:
91
+ print(f"Could not read PyPI versions for {package_name}: HTTP {error.code}", file=sys.stderr)
92
+ except Exception as error:
93
+ print(f"Could not read PyPI versions for {package_name}: {error}", file=sys.stderr)
94
+
95
+ try:
96
+ subprocess.run(["git", "fetch", "--tags", "--force"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
97
+ tags = subprocess.check_output(["git", "tag", "-l", "v*"], text=True)
98
+ for tag in tags.splitlines():
99
+ add_version(tag)
100
+ except Exception as error:
101
+ print(f"Could not read git tags: {error}", file=sys.stderr)
102
+
103
+ if versions:
104
+ def parsed(version):
105
+ return tuple(int(part) for part in version.split("."))
106
+ major, minor, patch = max(parsed(version) for version in versions)
107
+ next_version = f"{major}.{minor}.{patch + 1}"
108
+ else:
109
+ next_version = os.environ["INITIAL_VERSION"]
110
+
111
+ text = re.sub(r'^version = "[^"]+"$', f'version = "{next_version}"', text, count=1, flags=re.MULTILINE)
112
+ pyproject_path.write_text(text)
113
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
114
+ output.write(f"next_version={next_version}\n")
115
+ print(f"Publishing {package_name}=={next_version}")
116
+ PY
117
+
118
+ - name: Build distributions
119
+ if: steps.existing_release.outputs.published != 'true'
120
+ run: |
121
+ python -m pip install --upgrade build
122
+ python -m build
123
+ python -m tarfile -l dist/*.tar.gz
124
+ python -m zipfile -l dist/*.whl
125
+
126
+ - name: Publish
127
+ if: steps.existing_release.outputs.published != 'true'
128
+ uses: pypa/gh-action-pypi-publish@release/v1
129
+
130
+ - name: Create GitHub release
131
+ if: steps.existing_release.outputs.published != 'true'
132
+ env:
133
+ GH_TOKEN: ${{ github.token }}
134
+ NEXT_VERSION: ${{ steps.version.outputs.next_version }}
135
+ run: |
136
+ set -euo pipefail
137
+ gh release create "v${NEXT_VERSION}" \
138
+ --target "${GITHUB_SHA}" \
139
+ --title "v${NEXT_VERSION}" \
140
+ --notes "Published armature-mcp-analytics==${NEXT_VERSION} from ${GITHUB_SHA}."
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: armature-mcp-analytics
3
+ Version: 0.1.0
4
+ Summary: Armature analytics wrapper SDK for Python MCP servers.
5
+ Project-URL: Homepage, https://armature.tech
6
+ Project-URL: Repository, https://github.com/armature-tech/mcp-analytics-python
7
+ Author-email: Armature <support@armature.tech>
8
+ License-Expression: Apache-2.0
9
+ Keywords: analytics,armature,fastmcp,mcp,model-context-protocol,observability,telemetry
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Provides-Extra: fastmcp
20
+ Requires-Dist: fastmcp<4,>=2; extra == 'fastmcp'
21
+ Provides-Extra: mcp
22
+ Requires-Dist: mcp<2,>=1.27; extra == 'mcp'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # armature-mcp-analytics
26
+
27
+ Armature analytics for Python MCP servers. The v1 integration is FastMCP-first:
28
+ instrument a `FastMCP` instance, keep writing normal tools, and Armature records
29
+ tool calls with MCP-specific telemetry.
30
+
31
+ Install:
32
+
33
+ ```bash
34
+ pip install "armature-mcp-analytics[fastmcp]"
35
+ ```
36
+
37
+ ```python
38
+ import os
39
+ from fastmcp import FastMCP
40
+ from armature_mcp_analytics import instrument_fastmcp
41
+
42
+ mcp = FastMCP("Customer MCP")
43
+
44
+ instrument_fastmcp(
45
+ mcp,
46
+ {
47
+ "armature": {
48
+ "api_key": os.getenv("ANALYTICS_INGEST_API_KEY"),
49
+ "delivery": "await",
50
+ }
51
+ },
52
+ )
53
+
54
+ @mcp.tool
55
+ def lookup_customer(customer_id: str) -> dict:
56
+ """Look up a customer by id."""
57
+ return {"customer_id": customer_id, "status": "active"}
58
+
59
+ if __name__ == "__main__":
60
+ mcp.run()
61
+ ```
62
+
63
+ Agents see an optional `telemetry` object on each tool input schema. The SDK
64
+ strips that object before your handler runs, then emits an authenticated batch
65
+ to Armature.
66
+
67
+ Environment variables:
68
+
69
+ | Variable | Purpose |
70
+ | --- | --- |
71
+ | `ANALYTICS_INGEST_API_KEY` | Armature ingest API key. Missing keys no-op for local development. |
72
+ | `ANALYTICS_INGEST_URL` | Optional ingest endpoint override. |
@@ -0,0 +1,48 @@
1
+ # armature-mcp-analytics
2
+
3
+ Armature analytics for Python MCP servers. The v1 integration is FastMCP-first:
4
+ instrument a `FastMCP` instance, keep writing normal tools, and Armature records
5
+ tool calls with MCP-specific telemetry.
6
+
7
+ Install:
8
+
9
+ ```bash
10
+ pip install "armature-mcp-analytics[fastmcp]"
11
+ ```
12
+
13
+ ```python
14
+ import os
15
+ from fastmcp import FastMCP
16
+ from armature_mcp_analytics import instrument_fastmcp
17
+
18
+ mcp = FastMCP("Customer MCP")
19
+
20
+ instrument_fastmcp(
21
+ mcp,
22
+ {
23
+ "armature": {
24
+ "api_key": os.getenv("ANALYTICS_INGEST_API_KEY"),
25
+ "delivery": "await",
26
+ }
27
+ },
28
+ )
29
+
30
+ @mcp.tool
31
+ def lookup_customer(customer_id: str) -> dict:
32
+ """Look up a customer by id."""
33
+ return {"customer_id": customer_id, "status": "active"}
34
+
35
+ if __name__ == "__main__":
36
+ mcp.run()
37
+ ```
38
+
39
+ Agents see an optional `telemetry` object on each tool input schema. The SDK
40
+ strips that object before your handler runs, then emits an authenticated batch
41
+ to Armature.
42
+
43
+ Environment variables:
44
+
45
+ | Variable | Purpose |
46
+ | --- | --- |
47
+ | `ANALYTICS_INGEST_API_KEY` | Armature ingest API key. Missing keys no-op for local development. |
48
+ | `ANALYTICS_INGEST_URL` | Optional ingest endpoint override. |
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "armature-mcp-analytics"
7
+ version = "0.1.0"
8
+ description = "Armature analytics wrapper SDK for Python MCP servers."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { name = "Armature", email = "support@armature.tech" },
14
+ ]
15
+ keywords = [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "analytics",
19
+ "telemetry",
20
+ "observability",
21
+ "armature",
22
+ "fastmcp",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: Apache Software License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Topic :: Software Development :: Libraries :: Python Modules",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ fastmcp = [
37
+ "fastmcp>=2,<4",
38
+ ]
39
+ mcp = [
40
+ "mcp>=1.27,<2",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://armature.tech"
45
+ Repository = "https://github.com/armature-tech/mcp-analytics-python"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/armature_mcp_analytics"]
@@ -0,0 +1,47 @@
1
+ """Armature analytics helpers for Python MCP servers."""
2
+
3
+ from .events import (
4
+ build_actor_id,
5
+ build_event_id,
6
+ build_session_init_event,
7
+ build_tool_call_event,
8
+ normalize_session_id,
9
+ )
10
+ from .recorder import AnalyticsRecorder, create_analytics_recorder
11
+ from .schema import (
12
+ append_telemetry_hint,
13
+ create_telemetry_json_schema,
14
+ decorate_input_schema_with_telemetry,
15
+ extract_telemetry_arguments,
16
+ )
17
+ from .server import FastMCPInstrumentation, instrument_fastmcp, with_mcp_analytics
18
+ from .types import (
19
+ AnalyticsConfig,
20
+ AnalyticsIngestBatch,
21
+ AnalyticsIngestEvent,
22
+ McpClientInfo,
23
+ TelemetryArgs,
24
+ )
25
+
26
+ __all__ = [
27
+ "AnalyticsConfig",
28
+ "AnalyticsIngestBatch",
29
+ "AnalyticsIngestEvent",
30
+ "AnalyticsRecorder",
31
+ "FastMCPInstrumentation",
32
+ "McpClientInfo",
33
+ "TelemetryArgs",
34
+ "append_telemetry_hint",
35
+ "build_actor_id",
36
+ "build_event_id",
37
+ "build_session_init_event",
38
+ "build_tool_call_event",
39
+ "create_analytics_recorder",
40
+ "create_telemetry_json_schema",
41
+ "decorate_input_schema_with_telemetry",
42
+ "extract_telemetry_arguments",
43
+ "instrument_fastmcp",
44
+ "normalize_session_id",
45
+ "with_mcp_analytics",
46
+ ]
47
+
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any
8
+
9
+ from .types import ActorIdResolverInput, AnalyticsConfig, AnalyticsIngestBatch
10
+ from .utils import header_value, read_env
11
+
12
+ DEFAULT_ENDPOINT_URL = "https://app.armature.tech/api/mcp-analytics/ingest"
13
+ DEFAULT_TIMEOUT_MS = 500
14
+
15
+
16
+ def _armature(config: AnalyticsConfig | None) -> dict[str, Any]:
17
+ return dict((config or {}).get("armature") or {})
18
+
19
+
20
+ def _config_value(config: AnalyticsConfig | None, snake: str, camel: str, default: Any = None) -> Any:
21
+ armature = _armature(config)
22
+ if snake in armature:
23
+ return armature[snake]
24
+ if camel in armature:
25
+ return armature[camel]
26
+ return default
27
+
28
+
29
+ def resolve_endpoint_url(config: AnalyticsConfig | None = None) -> str:
30
+ return (
31
+ _config_value(config, "endpoint_url", "endpointUrl")
32
+ or read_env("ANALYTICS_INGEST_URL")
33
+ or DEFAULT_ENDPOINT_URL
34
+ )
35
+
36
+
37
+ def resolve_api_key(config: AnalyticsConfig | None = None) -> str | None:
38
+ return _config_value(config, "api_key", "apiKey") or read_env("ANALYTICS_INGEST_API_KEY")
39
+
40
+
41
+ async def resolve_actor_seed(config: AnalyticsConfig | None, input: ActorIdResolverInput) -> str:
42
+ actor_id = _config_value(config, "actor_id", "actorId")
43
+ if callable(actor_id):
44
+ value = actor_id(input)
45
+ if asyncio.iscoroutine(value) or isinstance(value, asyncio.Future):
46
+ value = await value
47
+ return str(value)
48
+ if actor_id:
49
+ return str(actor_id)
50
+
51
+ auth_info = input.get("authInfo") or {}
52
+ for key in ("token", "clientId", "apiKey", "principalId"):
53
+ value = auth_info.get(key)
54
+ if value:
55
+ return str(value)
56
+
57
+ authorization = header_value(input.get("headers"), "authorization")
58
+ if authorization:
59
+ return authorization
60
+ return "anonymous"
61
+
62
+
63
+ async def post_telemetry_event(
64
+ batch: AnalyticsIngestBatch,
65
+ config: AnalyticsConfig | None = None,
66
+ ) -> dict[str, Any]:
67
+ api_key = resolve_api_key(config)
68
+ if not api_key:
69
+ return {"skipped": True, "reason": "ingest_config_missing"}
70
+
71
+ body = json.dumps(batch, separators=(",", ":")).encode("utf-8")
72
+ request = urllib.request.Request(
73
+ resolve_endpoint_url(config),
74
+ data=body,
75
+ headers={
76
+ "Content-Type": "application/json",
77
+ "Authorization": f"Bearer {api_key}",
78
+ },
79
+ method="POST",
80
+ )
81
+ timeout_ms = _config_value(config, "timeout_ms", "timeoutMs", DEFAULT_TIMEOUT_MS)
82
+ timeout = float(timeout_ms) / 1000
83
+
84
+ def send() -> dict[str, Any]:
85
+ with urllib.request.urlopen(request, timeout=timeout) as response:
86
+ status = response.getcode()
87
+ return {"skipped": False, "ok": True, "status": status}
88
+
89
+ try:
90
+ return await asyncio.to_thread(send)
91
+ except urllib.error.HTTPError as error:
92
+ detail = error.read().decode("utf-8", errors="replace")
93
+ raise RuntimeError(f"Armature ingest failed with {error.code}: {detail}") from error
94
+
95
+
96
+ class FlushableEmitter:
97
+ def __init__(self, config: AnalyticsConfig | None = None) -> None:
98
+ self.config = config or {}
99
+ self._pending: set[asyncio.Task[None]] = set()
100
+
101
+ def _enabled(self) -> bool:
102
+ return _config_value(self.config, "enabled", "enabled", True) is not False
103
+
104
+ def _delivery(self) -> str:
105
+ return str(_config_value(self.config, "delivery", "delivery", "background"))
106
+
107
+ def _emit_callable(self):
108
+ return _config_value(self.config, "emit", "emit")
109
+
110
+ def _on_error(self):
111
+ return _config_value(self.config, "on_error", "onError")
112
+
113
+ async def _run(self, batch: AnalyticsIngestBatch) -> None:
114
+ try:
115
+ emit = self._emit_callable()
116
+ if emit:
117
+ result = emit(batch)
118
+ if asyncio.iscoroutine(result) or isinstance(result, asyncio.Future):
119
+ await result
120
+ else:
121
+ await post_telemetry_event(batch, self.config)
122
+ except Exception as error:
123
+ # Telemetry delivery should not crash the host MCP server. Await
124
+ # mode waits for the attempt to finish; failures are surfaced only
125
+ # through the optional error hook, matching the JS SDK contract.
126
+ on_error = self._on_error()
127
+ if on_error:
128
+ try:
129
+ result = on_error(error, batch)
130
+ if asyncio.iscoroutine(result) or isinstance(result, asyncio.Future):
131
+ await result
132
+ except Exception:
133
+ pass
134
+
135
+ async def emit_batch(self, batch: AnalyticsIngestBatch) -> None:
136
+ if not self._enabled():
137
+ return
138
+ if self._delivery() == "await":
139
+ await self._run(batch)
140
+ return
141
+ loop = asyncio.get_running_loop()
142
+ task = loop.create_task(self._run(batch))
143
+ self._pending.add(task)
144
+ task.add_done_callback(lambda done: self._pending.discard(done))
145
+
146
+ async def flush(self) -> None:
147
+ while self._pending:
148
+ await asyncio.gather(*list(self._pending), return_exceptions=True)
149
+
150
+
151
+ def create_flushable_emitter(config: AnalyticsConfig | None = None) -> FlushableEmitter:
152
+ return FlushableEmitter(config)