specsmith 0.6.0.dev230__tar.gz → 0.6.0.dev231__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.
- {specsmith-0.6.0.dev230/src/specsmith.egg-info → specsmith-0.6.0.dev231}/PKG-INFO +1 -1
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/pyproject.toml +1 -1
- specsmith-0.6.0.dev231/src/specsmith/agent/mcp.py +387 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/cli.py +12 -5
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith.egg-info/SOURCES.txt +1 -0
- specsmith-0.6.0.dev231/tests/test_mcp_client.py +157 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_phase34_completion.py +29 -18
- specsmith-0.6.0.dev230/src/specsmith/agent/mcp.py +0 -117
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/LICENSE +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/README.md +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/setup.cfg +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/belief.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/certainty.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/failure_graph.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/py.typed +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/recovery.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/session.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/stress_tester.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/epistemic/trace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/__main__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/broker.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/chat_runner.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/cleanup.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/events.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/indexer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/memory.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/orchestrator.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/repl.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/router.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/rules.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/safety.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/tools.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/agent/verifier.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/architect.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/auditor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/auth.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/compressor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/config.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/console_utils.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/credit_analyzer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/credits.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/differ.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/doctor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/epistemic/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/epistemic/belief.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/epistemic/certainty.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/epistemic/failure_graph.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/epistemic/recovery.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/epistemic/stress_tester.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/executor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/exporter.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/app.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/main_window.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/session_tab.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/theme.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/chat_view.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/input_bar.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/provider_bar.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/token_meter.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/tool_panel.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/widgets/update_checker.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/gui/worker.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/importer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/agent_skill.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/languages.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/ledger.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/patent.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/phase.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/plugins.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/profiles.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/rate_limits.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/releaser.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/requirements.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/requirements_parser.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/retrieval.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/scaffolder.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/serve.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/session.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/skills.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/contributing.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/license-MIT.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/community/security.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/go/go.mod.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/go/main.go.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/js/package.json.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/rust/main.rs.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/tool_installer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/toolrules.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/tools.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/trace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/updater.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/validator.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/wireframes.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith/workspace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_CMD_001.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_auditor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_chat_diff_decision.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_chat_stdin_protocol.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_cli.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_cli_workflows_history_drive.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_compressor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_e2e_nexus.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_epistemic.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_importer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_integrations.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_nexus.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_phase1_4_new.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_rate_limits.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_scaffolder.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_skill_marketplace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_smoke.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_tools.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_validator.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev231}/tests/test_vcs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: specsmith
|
|
3
|
-
Version: 0.6.0.
|
|
3
|
+
Version: 0.6.0.dev231
|
|
4
4
|
Summary: Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands.
|
|
5
5
|
Author: BitConcepts
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "specsmith"
|
|
7
|
-
version = "0.6.0.
|
|
7
|
+
version = "0.6.0.dev231"
|
|
8
8
|
description = "Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Real MCP (Model Context Protocol) client for Nexus (REQ-121, REQ-130).
|
|
4
|
+
|
|
5
|
+
Replaces the prior loader-only stub with a working JSON-RPC 2.0 client
|
|
6
|
+
that drives the official MCP handshake over stdio:
|
|
7
|
+
|
|
8
|
+
* ``initialize`` request -> response (capability negotiation).
|
|
9
|
+
* ``notifications/initialized`` notification.
|
|
10
|
+
* ``tools/list`` request -> response (tool catalog discovery).
|
|
11
|
+
* ``tools/call`` requests -> responses (per-tool invocation).
|
|
12
|
+
|
|
13
|
+
The Specsmith safety middleware still wraps every call: see
|
|
14
|
+
``MCPTool.invoke_with_safety``. Servers configured via ``.specsmith/mcp.yml``
|
|
15
|
+
are listed at the top of every ``specsmith chat`` session and exposed to
|
|
16
|
+
the orchestrator as additional Nexus tools.
|
|
17
|
+
|
|
18
|
+
Protocol pin: 2024-11-05 (current stable). Servers that advertise a newer
|
|
19
|
+
version still work because MCP guarantees backwards compatibility within
|
|
20
|
+
the same major track.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import subprocess
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
36
|
+
DEFAULT_REQUEST_TIMEOUT = 30.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class MCPServerSpec:
|
|
41
|
+
"""Static configuration for an MCP server (mirrors `.specsmith/mcp.yml`)."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
command: str
|
|
45
|
+
args: list[str] = field(default_factory=list)
|
|
46
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class MCPToolDescriptor:
|
|
51
|
+
"""One tool advertised by an MCP server's ``tools/list`` response."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
input_schema: dict[str, Any]
|
|
56
|
+
server_name: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MCPError(RuntimeError):
|
|
60
|
+
"""Raised on transport or JSON-RPC errors from an MCP server."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, *, code: int, message: str, data: Any = None) -> None:
|
|
63
|
+
super().__init__(f"MCP error {code}: {message}")
|
|
64
|
+
self.code = code
|
|
65
|
+
self.detail = message
|
|
66
|
+
self.data = data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MCPSession:
|
|
70
|
+
"""One stdio-attached MCP server with full JSON-RPC framing.
|
|
71
|
+
|
|
72
|
+
The session owns the subprocess lifecycle. ``open()`` performs the
|
|
73
|
+
initialize handshake and discovery; ``call_tool()`` drives ``tools/call``;
|
|
74
|
+
``close()`` flushes pending requests and terminates the child.
|
|
75
|
+
Concurrent calls into a single session are not supported (one in-flight
|
|
76
|
+
request at a time, matching the stdio MCP transport model).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, spec: MCPServerSpec) -> None:
|
|
80
|
+
self.spec = spec
|
|
81
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
82
|
+
self._next_id = 1
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
self._tools: list[MCPToolDescriptor] = []
|
|
85
|
+
self._closed = False
|
|
86
|
+
|
|
87
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def open(self, *, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> list[MCPToolDescriptor]:
|
|
90
|
+
"""Spawn the server, run the initialize handshake, return discovered tools."""
|
|
91
|
+
env = {**self.spec.env}
|
|
92
|
+
self._proc = subprocess.Popen( # noqa: S603 - argv is user-configured
|
|
93
|
+
[self.spec.command, *self.spec.args],
|
|
94
|
+
stdin=subprocess.PIPE,
|
|
95
|
+
stdout=subprocess.PIPE,
|
|
96
|
+
stderr=subprocess.PIPE,
|
|
97
|
+
env=env or None,
|
|
98
|
+
bufsize=0,
|
|
99
|
+
)
|
|
100
|
+
self._request(
|
|
101
|
+
"initialize",
|
|
102
|
+
{
|
|
103
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
104
|
+
"capabilities": {"tools": {}},
|
|
105
|
+
"clientInfo": {"name": "specsmith", "version": "0"},
|
|
106
|
+
},
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
)
|
|
109
|
+
# Per spec: send notifications/initialized after a successful initialize.
|
|
110
|
+
self._notify("notifications/initialized", {})
|
|
111
|
+
result = self._request("tools/list", {}, timeout=timeout)
|
|
112
|
+
raw_tools = result.get("tools", []) if isinstance(result, dict) else []
|
|
113
|
+
self._tools = []
|
|
114
|
+
for entry in raw_tools:
|
|
115
|
+
if not isinstance(entry, dict):
|
|
116
|
+
continue
|
|
117
|
+
name = entry.get("name")
|
|
118
|
+
if not name:
|
|
119
|
+
continue
|
|
120
|
+
schema = entry.get("inputSchema", {})
|
|
121
|
+
self._tools.append(
|
|
122
|
+
MCPToolDescriptor(
|
|
123
|
+
name=str(name),
|
|
124
|
+
description=str(entry.get("description", "")),
|
|
125
|
+
input_schema=schema if isinstance(schema, dict) else {},
|
|
126
|
+
server_name=self.spec.name,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return list(self._tools)
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
"""Terminate the server. Idempotent."""
|
|
133
|
+
if self._closed:
|
|
134
|
+
return
|
|
135
|
+
self._closed = True
|
|
136
|
+
if self._proc is None:
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
if self._proc.stdin and not self._proc.stdin.closed:
|
|
140
|
+
self._proc.stdin.close()
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
try:
|
|
144
|
+
self._proc.terminate()
|
|
145
|
+
self._proc.wait(timeout=2.0)
|
|
146
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
147
|
+
with contextlib.suppress(OSError):
|
|
148
|
+
self._proc.kill()
|
|
149
|
+
|
|
150
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def tools(self) -> list[MCPToolDescriptor]:
|
|
154
|
+
"""Return the catalog discovered during ``open()``."""
|
|
155
|
+
return list(self._tools)
|
|
156
|
+
|
|
157
|
+
def call_tool(
|
|
158
|
+
self,
|
|
159
|
+
name: str,
|
|
160
|
+
arguments: dict[str, Any] | None = None,
|
|
161
|
+
*,
|
|
162
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Invoke ``tools/call`` and return a flat string result.
|
|
165
|
+
|
|
166
|
+
MCP returns content blocks; we concatenate text blocks and report
|
|
167
|
+
non-text blocks descriptively so downstream consumers can render a
|
|
168
|
+
single string.
|
|
169
|
+
"""
|
|
170
|
+
params: dict[str, Any] = {"name": name}
|
|
171
|
+
if arguments:
|
|
172
|
+
params["arguments"] = arguments
|
|
173
|
+
result = self._request("tools/call", params, timeout=timeout)
|
|
174
|
+
if not isinstance(result, dict):
|
|
175
|
+
return str(result)
|
|
176
|
+
if result.get("isError"):
|
|
177
|
+
return f"mcp error: {_format_content(result.get('content', []))}"
|
|
178
|
+
return _format_content(result.get("content", []))
|
|
179
|
+
|
|
180
|
+
# ── JSON-RPC framing ──────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def _request(
|
|
183
|
+
self,
|
|
184
|
+
method: str,
|
|
185
|
+
params: dict[str, Any],
|
|
186
|
+
*,
|
|
187
|
+
timeout: float,
|
|
188
|
+
) -> Any:
|
|
189
|
+
with self._lock:
|
|
190
|
+
req_id = self._next_id
|
|
191
|
+
self._next_id += 1
|
|
192
|
+
self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
|
|
193
|
+
response = self._read_response_for(req_id, timeout)
|
|
194
|
+
if "error" in response:
|
|
195
|
+
err = response["error"]
|
|
196
|
+
raise MCPError(
|
|
197
|
+
code=int(err.get("code", -1)),
|
|
198
|
+
message=str(err.get("message", "(no message)")),
|
|
199
|
+
data=err.get("data"),
|
|
200
|
+
)
|
|
201
|
+
return response.get("result", {})
|
|
202
|
+
|
|
203
|
+
def _notify(self, method: str, params: dict[str, Any]) -> None:
|
|
204
|
+
with self._lock:
|
|
205
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
206
|
+
|
|
207
|
+
def _send(self, payload: dict[str, Any]) -> None:
|
|
208
|
+
if self._proc is None or self._proc.stdin is None:
|
|
209
|
+
raise MCPError(code=-32000, message="server not open")
|
|
210
|
+
line = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
|
|
211
|
+
try:
|
|
212
|
+
self._proc.stdin.write(line)
|
|
213
|
+
self._proc.stdin.flush()
|
|
214
|
+
except (OSError, BrokenPipeError) as exc:
|
|
215
|
+
raise MCPError(code=-32001, message=f"send failed: {exc}") from exc
|
|
216
|
+
|
|
217
|
+
def _read_response_for(self, req_id: int, timeout: float) -> dict[str, Any]:
|
|
218
|
+
if self._proc is None or self._proc.stdout is None:
|
|
219
|
+
raise MCPError(code=-32000, message="server not open")
|
|
220
|
+
deadline = time.monotonic() + timeout
|
|
221
|
+
while time.monotonic() < deadline:
|
|
222
|
+
line = self._proc.stdout.readline()
|
|
223
|
+
if not line:
|
|
224
|
+
stderr_tail = b""
|
|
225
|
+
if self._proc.stderr is not None:
|
|
226
|
+
try:
|
|
227
|
+
stderr_tail = self._proc.stderr.read() or b""
|
|
228
|
+
except OSError:
|
|
229
|
+
stderr_tail = b""
|
|
230
|
+
raise MCPError(
|
|
231
|
+
code=-32002,
|
|
232
|
+
message=f"mcp server closed: {stderr_tail.decode('utf-8', 'replace').strip()}",
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
msg = json.loads(line.decode("utf-8", "replace"))
|
|
236
|
+
except ValueError:
|
|
237
|
+
continue
|
|
238
|
+
if not isinstance(msg, dict):
|
|
239
|
+
continue
|
|
240
|
+
if msg.get("id") == req_id:
|
|
241
|
+
return msg
|
|
242
|
+
raise MCPError(code=-32003, message=f"timeout waiting for response to id={req_id}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _format_content(blocks: Any) -> str:
|
|
246
|
+
"""Concatenate MCP content blocks into a single human-readable string."""
|
|
247
|
+
if not isinstance(blocks, list):
|
|
248
|
+
return str(blocks)
|
|
249
|
+
parts: list[str] = []
|
|
250
|
+
for block in blocks:
|
|
251
|
+
if not isinstance(block, dict):
|
|
252
|
+
continue
|
|
253
|
+
kind = block.get("type", "")
|
|
254
|
+
if kind == "text":
|
|
255
|
+
parts.append(str(block.get("text", "")))
|
|
256
|
+
elif kind == "image":
|
|
257
|
+
parts.append(f"[image: {block.get('mimeType', 'unknown')}]")
|
|
258
|
+
elif kind == "resource":
|
|
259
|
+
uri = (block.get("resource") or {}).get("uri", "?")
|
|
260
|
+
parts.append(f"[resource: {uri}]")
|
|
261
|
+
else:
|
|
262
|
+
parts.append(f"[unknown block: {kind}]")
|
|
263
|
+
return "\n".join(parts) if parts else "(empty mcp response)"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass
|
|
267
|
+
class MCPTool:
|
|
268
|
+
"""A Nexus-side handle that wraps one descriptor + an open session."""
|
|
269
|
+
|
|
270
|
+
descriptor: MCPToolDescriptor
|
|
271
|
+
session: MCPSession
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def name(self) -> str:
|
|
275
|
+
return self.descriptor.name
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def server(self) -> str:
|
|
279
|
+
return self.descriptor.server_name
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def description(self) -> str:
|
|
283
|
+
return self.descriptor.description
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def spec(self) -> MCPServerSpec:
|
|
287
|
+
"""Back-compat shim — older callers expect a `.spec` attribute."""
|
|
288
|
+
return self.session.spec
|
|
289
|
+
|
|
290
|
+
def invoke(self, arguments: dict[str, Any] | None = None) -> str:
|
|
291
|
+
"""Direct invocation (no safety middleware)."""
|
|
292
|
+
return self.session.call_tool(self.descriptor.name, arguments)
|
|
293
|
+
|
|
294
|
+
def invoke_with_safety(
|
|
295
|
+
self,
|
|
296
|
+
arguments: dict[str, Any] | None,
|
|
297
|
+
safety_check: Callable[[str, dict[str, Any]], tuple[bool, str]] | None,
|
|
298
|
+
) -> str:
|
|
299
|
+
"""Invoke after running the supplied safety check.
|
|
300
|
+
|
|
301
|
+
The check returns ``(allowed, reason)``. When disallowed, the call
|
|
302
|
+
is not made and a redacted error string is returned.
|
|
303
|
+
"""
|
|
304
|
+
if safety_check is not None:
|
|
305
|
+
allowed, reason = safety_check(self.descriptor.name, arguments or {})
|
|
306
|
+
if not allowed:
|
|
307
|
+
return f"mcp blocked by safety: {reason}"
|
|
308
|
+
return self.invoke(arguments or None)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ── Loader-style helpers (back-compat with prior callers) ────────────────
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _read_specs(project_dir: Path) -> list[MCPServerSpec]:
|
|
315
|
+
cfg_path = Path(project_dir) / ".specsmith" / "mcp.yml"
|
|
316
|
+
if not cfg_path.is_file():
|
|
317
|
+
return []
|
|
318
|
+
try:
|
|
319
|
+
import yaml
|
|
320
|
+
|
|
321
|
+
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or []
|
|
322
|
+
except Exception: # noqa: BLE001
|
|
323
|
+
return []
|
|
324
|
+
if not isinstance(raw, list):
|
|
325
|
+
return []
|
|
326
|
+
out: list[MCPServerSpec] = []
|
|
327
|
+
for entry in raw:
|
|
328
|
+
if not isinstance(entry, dict):
|
|
329
|
+
continue
|
|
330
|
+
name = str(entry.get("name", "")).strip()
|
|
331
|
+
command = str(entry.get("command", "")).strip()
|
|
332
|
+
if not name or not command:
|
|
333
|
+
continue
|
|
334
|
+
args_raw = entry.get("args", []) or []
|
|
335
|
+
env_raw = entry.get("env", {}) or {}
|
|
336
|
+
out.append(
|
|
337
|
+
MCPServerSpec(
|
|
338
|
+
name=name,
|
|
339
|
+
command=command,
|
|
340
|
+
args=[str(a) for a in args_raw if isinstance(a, (str, int, float))],
|
|
341
|
+
env={str(k): str(v) for k, v in env_raw.items()},
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def load_mcp_tools(project_dir: Path) -> list[MCPTool]:
|
|
348
|
+
"""Open every configured MCP server and return its tools (back-compat).
|
|
349
|
+
|
|
350
|
+
Servers that fail to open are silently skipped. Returns an empty list
|
|
351
|
+
when no servers are configured. The underlying sessions remain open
|
|
352
|
+
until the process exits — convenient for one-shot scripts and tests.
|
|
353
|
+
Long-running consumers should prefer :func:`open_mcp_sessions` and
|
|
354
|
+
explicitly ``close()`` each session.
|
|
355
|
+
"""
|
|
356
|
+
sessions = open_mcp_sessions(project_dir)
|
|
357
|
+
out: list[MCPTool] = []
|
|
358
|
+
for session in sessions:
|
|
359
|
+
for descriptor in session.tools:
|
|
360
|
+
out.append(MCPTool(descriptor=descriptor, session=session))
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def open_mcp_sessions(project_dir: Path) -> list[MCPSession]:
|
|
365
|
+
"""Open all configured MCP sessions and return them. Caller owns close."""
|
|
366
|
+
out: list[MCPSession] = []
|
|
367
|
+
for spec in _read_specs(project_dir):
|
|
368
|
+
session = MCPSession(spec)
|
|
369
|
+
try:
|
|
370
|
+
session.open()
|
|
371
|
+
except (OSError, MCPError):
|
|
372
|
+
session.close()
|
|
373
|
+
continue
|
|
374
|
+
out.append(session)
|
|
375
|
+
return out
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"MCP_PROTOCOL_VERSION",
|
|
380
|
+
"MCPError",
|
|
381
|
+
"MCPServerSpec",
|
|
382
|
+
"MCPSession",
|
|
383
|
+
"MCPTool",
|
|
384
|
+
"MCPToolDescriptor",
|
|
385
|
+
"load_mcp_tools",
|
|
386
|
+
"open_mcp_sessions",
|
|
387
|
+
]
|
|
@@ -5288,13 +5288,20 @@ def chat_cmd(
|
|
|
5288
5288
|
if rules_prefix:
|
|
5289
5289
|
emitter.token(msg_block, "[project rules loaded]\n")
|
|
5290
5290
|
|
|
5291
|
-
# Surface configured MCP servers (REQ-121). The
|
|
5292
|
-
#
|
|
5293
|
-
#
|
|
5291
|
+
# Surface configured MCP servers (REQ-121, REQ-130). The real client
|
|
5292
|
+
# opens each server, runs the initialize handshake, and discovers its
|
|
5293
|
+
# tools; the safety middleware still gates every actual invocation.
|
|
5294
|
+
# Here we just announce availability so consumers can render the list.
|
|
5294
5295
|
mcp_tools = load_mcp_tools(root)
|
|
5295
5296
|
if mcp_tools:
|
|
5296
|
-
|
|
5297
|
-
|
|
5297
|
+
servers: dict[str, list[str]] = {}
|
|
5298
|
+
for tool in mcp_tools:
|
|
5299
|
+
servers.setdefault(tool.server, []).append(tool.name)
|
|
5300
|
+
summary = ", ".join(f"{srv} ({len(names)})" for srv, names in servers.items())
|
|
5301
|
+
emitter.token(
|
|
5302
|
+
msg_block,
|
|
5303
|
+
f"[mcp: {len(mcp_tools)} tool(s) across {len(servers)} server(s): {summary}]\n",
|
|
5304
|
+
)
|
|
5298
5305
|
|
|
5299
5306
|
# Pick a tier (REQ-122) so consumers know which model is in play.
|
|
5300
5307
|
_utt_lower = utterance.lower()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: specsmith
|
|
3
|
-
Version: 0.6.0.
|
|
3
|
+
Version: 0.6.0.dev231
|
|
4
4
|
Summary: Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands.
|
|
5
5
|
Author: BitConcepts
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""End-to-end tests for the real MCP JSON-RPC client (REQ-130 / TEST-130).
|
|
4
|
+
|
|
5
|
+
Uses ``tests/fixtures/mcp_fake_server.py`` so we can drive the full
|
|
6
|
+
handshake without depending on any external MCP server installation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from specsmith.agent.mcp import (
|
|
17
|
+
MCP_PROTOCOL_VERSION,
|
|
18
|
+
MCPError,
|
|
19
|
+
MCPServerSpec,
|
|
20
|
+
MCPSession,
|
|
21
|
+
MCPTool,
|
|
22
|
+
open_mcp_sessions,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|
26
|
+
FAKE = FIXTURES / "mcp_fake_server.py"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _spec(env: dict[str, str] | None = None) -> MCPServerSpec:
|
|
30
|
+
return MCPServerSpec(
|
|
31
|
+
name="fake",
|
|
32
|
+
command=sys.executable,
|
|
33
|
+
args=[str(FAKE)],
|
|
34
|
+
env=env or {},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Discovery / handshake (TEST-130a..c) ─────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_session_open_runs_handshake_and_lists_tools() -> None:
|
|
42
|
+
session = MCPSession(_spec())
|
|
43
|
+
try:
|
|
44
|
+
tools = session.open()
|
|
45
|
+
assert {t.name for t in tools} == {"echo", "boom"}
|
|
46
|
+
echo = next(t for t in tools if t.name == "echo")
|
|
47
|
+
assert "Echo" in echo.description
|
|
48
|
+
assert echo.input_schema.get("required") == ["text"]
|
|
49
|
+
assert echo.server_name == "fake"
|
|
50
|
+
finally:
|
|
51
|
+
session.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_session_open_pins_protocol_version_constant() -> None:
|
|
55
|
+
# Make sure the public protocol-pin constant is the latest stable.
|
|
56
|
+
assert MCP_PROTOCOL_VERSION == "2024-11-05"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_session_close_is_idempotent() -> None:
|
|
60
|
+
session = MCPSession(_spec())
|
|
61
|
+
session.open()
|
|
62
|
+
session.close()
|
|
63
|
+
session.close() # second close must not raise
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Tool invocation (TEST-130d..g) ───────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_call_tool_returns_concatenated_text_blocks() -> None:
|
|
70
|
+
session = MCPSession(_spec())
|
|
71
|
+
try:
|
|
72
|
+
session.open()
|
|
73
|
+
result = session.call_tool("echo", {"text": "hello world"})
|
|
74
|
+
assert result == "hello world"
|
|
75
|
+
finally:
|
|
76
|
+
session.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_call_tool_iserror_is_prefixed() -> None:
|
|
80
|
+
session = MCPSession(_spec())
|
|
81
|
+
try:
|
|
82
|
+
session.open()
|
|
83
|
+
result = session.call_tool("boom", {})
|
|
84
|
+
assert result.startswith("mcp error:")
|
|
85
|
+
assert "intentional boom" in result
|
|
86
|
+
finally:
|
|
87
|
+
session.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_call_tool_unknown_name_raises_mcp_error() -> None:
|
|
91
|
+
session = MCPSession(_spec())
|
|
92
|
+
try:
|
|
93
|
+
session.open()
|
|
94
|
+
with pytest.raises(MCPError) as exc:
|
|
95
|
+
session.call_tool("does-not-exist", {})
|
|
96
|
+
assert exc.value.code == -32601
|
|
97
|
+
finally:
|
|
98
|
+
session.close()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_mcp_tool_invoke_with_safety_blocks_disallowed_payloads() -> None:
|
|
102
|
+
session = MCPSession(_spec())
|
|
103
|
+
try:
|
|
104
|
+
session.open()
|
|
105
|
+
echo = next(t for t in session.tools if t.name == "echo")
|
|
106
|
+
tool = MCPTool(descriptor=echo, session=session)
|
|
107
|
+
|
|
108
|
+
def _check(name: str, args: dict[str, object]) -> tuple[bool, str]:
|
|
109
|
+
text = str(args.get("text", ""))
|
|
110
|
+
if "rm -rf" in text:
|
|
111
|
+
return False, "destructive command refused"
|
|
112
|
+
return True, ""
|
|
113
|
+
|
|
114
|
+
# Allowed → flows through to call_tool.
|
|
115
|
+
assert tool.invoke_with_safety({"text": "ok"}, _check) == "ok"
|
|
116
|
+
# Disallowed → returns redacted message and never calls the server.
|
|
117
|
+
blocked = tool.invoke_with_safety({"text": "rm -rf /"}, _check)
|
|
118
|
+
assert blocked.startswith("mcp blocked by safety:")
|
|
119
|
+
finally:
|
|
120
|
+
session.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Failure modes (TEST-130h..j) ─────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_session_open_raises_when_server_crashes_during_initialize() -> None:
|
|
127
|
+
session = MCPSession(_spec(env={"MCP_FAKE_CRASH_ON": "initialize"}))
|
|
128
|
+
try:
|
|
129
|
+
with pytest.raises(MCPError) as exc:
|
|
130
|
+
session.open()
|
|
131
|
+
assert exc.value.code == -32002
|
|
132
|
+
finally:
|
|
133
|
+
session.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_open_mcp_sessions_skips_servers_that_fail_to_start(
|
|
137
|
+
tmp_path: Path,
|
|
138
|
+
) -> None:
|
|
139
|
+
cfg = tmp_path / ".specsmith"
|
|
140
|
+
cfg.mkdir()
|
|
141
|
+
# First entry resolves, second does not.
|
|
142
|
+
cfg.joinpath("mcp.yml").write_text(
|
|
143
|
+
"- name: real\n"
|
|
144
|
+
f" command: {sys.executable}\n"
|
|
145
|
+
f" args: ['{FAKE.as_posix()}']\n"
|
|
146
|
+
"- name: missing\n"
|
|
147
|
+
" command: definitely-not-a-real-mcp-binary-xyz\n",
|
|
148
|
+
encoding="utf-8",
|
|
149
|
+
)
|
|
150
|
+
sessions = open_mcp_sessions(tmp_path)
|
|
151
|
+
try:
|
|
152
|
+
assert len(sessions) == 1
|
|
153
|
+
assert sessions[0].spec.name == "real"
|
|
154
|
+
assert {t.name for t in sessions[0].tools} == {"echo", "boom"}
|
|
155
|
+
finally:
|
|
156
|
+
for s in sessions:
|
|
157
|
+
s.close()
|