publicmcp 0.2.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.
publicmcp-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship made available under
|
|
36
|
+
the License, as indicated by a copyright notice that is included in
|
|
37
|
+
or attached to the work (an example is provided in the Appendix below).
|
|
38
|
+
|
|
39
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
40
|
+
form, that is based on (or derived from) the Work and for which the
|
|
41
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
42
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
43
|
+
of this License, Derivative Works shall not include works that remain
|
|
44
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
45
|
+
the Work and Derivative Works thereof.
|
|
46
|
+
|
|
47
|
+
"Contribution" shall mean, as submitted to the Licensor for inclusion
|
|
48
|
+
in the Work by the copyright owner or by an individual or Legal Entity
|
|
49
|
+
authorized to submit on behalf of the copyright owner.
|
|
50
|
+
|
|
51
|
+
"Contributor" shall mean Licensor and any Legal Entity on behalf of
|
|
52
|
+
whom a Contribution has been received by the Licensor and included
|
|
53
|
+
within the Work.
|
|
54
|
+
|
|
55
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
56
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
57
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
58
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
59
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
60
|
+
Work and such Derivative Works in Source or Object form.
|
|
61
|
+
|
|
62
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
63
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
64
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
65
|
+
(except as stated in this section) patent license to make, have made,
|
|
66
|
+
use, offer to sell, sell, import, and otherwise transfer the Work.
|
|
67
|
+
|
|
68
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
69
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
70
|
+
modifications, and in Source or Object form, provided that You
|
|
71
|
+
meet the following conditions:
|
|
72
|
+
|
|
73
|
+
(a) You must give any other recipients of the Work or Derivative
|
|
74
|
+
Works a copy of this License; and
|
|
75
|
+
|
|
76
|
+
(b) You must cause any modified files to carry prominent notices
|
|
77
|
+
stating that You changed the files; and
|
|
78
|
+
|
|
79
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
80
|
+
that You distribute, all copyright, patent, trademark, and
|
|
81
|
+
attribution notices from the Source form of the Work; and
|
|
82
|
+
|
|
83
|
+
(d) If the Work includes a "NOTICE" text file, you must include a
|
|
84
|
+
readable copy of the attribution notices contained within such
|
|
85
|
+
NOTICE file, in at least one of the following places: within a
|
|
86
|
+
NOTICE text provided with the Derivative Works; within the Source
|
|
87
|
+
form or documentation, if provided along with the Derivative Works;
|
|
88
|
+
or, within a display generated by the Derivative Works, if and
|
|
89
|
+
wherever such third-party notices normally appear.
|
|
90
|
+
|
|
91
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
92
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
93
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
94
|
+
this License, without any additional terms or conditions.
|
|
95
|
+
|
|
96
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
97
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
98
|
+
except as required for reasonable and customary use in describing the
|
|
99
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
100
|
+
|
|
101
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed
|
|
102
|
+
to in writing, Licensor provides the Work (and each Contributor
|
|
103
|
+
provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES
|
|
104
|
+
OR CONDITIONS OF ANY KIND, either express or implied.
|
|
105
|
+
|
|
106
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
107
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
108
|
+
unless required by applicable law, shall any Contributor be liable
|
|
109
|
+
to You for damages, including any direct, indirect, special,
|
|
110
|
+
incidental, or exemplary damages of any character arising as a result
|
|
111
|
+
of this License or out of the use or inability to use the Work.
|
|
112
|
+
|
|
113
|
+
9. Accepting Warranty or Liability. While redistributing the Work or
|
|
114
|
+
Derivative Works thereof, You may choose to offer, and charge a fee
|
|
115
|
+
for, acceptance of support, warranty, indemnity, or other liability
|
|
116
|
+
obligations and/or rights consistent with this License. However, in
|
|
117
|
+
accepting such obligations, You may offer such obligations only on
|
|
118
|
+
Your own behalf and on a sole responsibility basis.
|
|
119
|
+
|
|
120
|
+
END OF TERMS AND CONDITIONS
|
|
121
|
+
|
|
122
|
+
Copyright 2026 Joseph Provence / Salt Creative LLC
|
|
123
|
+
|
|
124
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
125
|
+
you may not use this file except in compliance with the License.
|
|
126
|
+
You may obtain a copy of the License at
|
|
127
|
+
|
|
128
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
publicmcp-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: publicmcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Build a PUBLICMCP-compliant business identity MCP server in minutes
|
|
5
|
+
Project-URL: Homepage, https://publicmcp.org
|
|
6
|
+
Project-URL: Repository, https://github.com/joeprovence/publicmcp
|
|
7
|
+
Project-URL: Spec, https://github.com/joeprovence/publicmcp/blob/main/SPEC.md
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,ai,business,fastmcp,identity,mcp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: click>=8.0
|
|
23
|
+
Requires-Dist: fastmcp>=3.2
|
|
24
|
+
Requires-Dist: starlette>=0.40
|
|
25
|
+
Requires-Dist: uvicorn>=0.30
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
publicmcp/cli.py — CLI entry point.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
publicmcp init — create a starter business.json
|
|
6
|
+
publicmcp serve — start the PUBLICMCP server
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
from publicmcp import __version__
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.version_option(version=__version__, prog_name="publicmcp")
|
|
23
|
+
def cli():
|
|
24
|
+
"""PUBLICMCP — business identity MCP server.
|
|
25
|
+
|
|
26
|
+
Build a spec-compliant MCP server for your business in minutes.
|
|
27
|
+
|
|
28
|
+
\b
|
|
29
|
+
Quickstart:
|
|
30
|
+
publicmcp init # create business.json
|
|
31
|
+
publicmcp serve # start server on :8000
|
|
32
|
+
|
|
33
|
+
Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# publicmcp init
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
@cli.command()
|
|
42
|
+
@click.option(
|
|
43
|
+
"--output", "-o",
|
|
44
|
+
default="business.json",
|
|
45
|
+
show_default=True,
|
|
46
|
+
help="Output path for the business.json template.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--force", "-f",
|
|
50
|
+
is_flag=True,
|
|
51
|
+
default=False,
|
|
52
|
+
help="Overwrite existing file without prompting.",
|
|
53
|
+
)
|
|
54
|
+
def init(output: str, force: bool) -> None:
|
|
55
|
+
"""Create a starter business.json template.
|
|
56
|
+
|
|
57
|
+
Generates a spec-compliant business.json with placeholder values.
|
|
58
|
+
Edit the file, then run 'publicmcp serve' to start your server.
|
|
59
|
+
"""
|
|
60
|
+
output_path = Path(output)
|
|
61
|
+
|
|
62
|
+
if output_path.exists() and not force:
|
|
63
|
+
click.confirm(
|
|
64
|
+
f"{output_path} already exists. Overwrite?",
|
|
65
|
+
abort=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
template_path = Path(__file__).parent / "templates" / "business.json"
|
|
69
|
+
shutil.copy(template_path, output_path)
|
|
70
|
+
|
|
71
|
+
click.echo(f"Created {output_path}")
|
|
72
|
+
click.echo("")
|
|
73
|
+
click.echo("Next steps:")
|
|
74
|
+
click.echo(f" 1. Edit {output_path} with your business details")
|
|
75
|
+
click.echo(" 2. Run: publicmcp serve")
|
|
76
|
+
click.echo(" 3. Connect your AI client to: http://localhost:8000/mcp")
|
|
77
|
+
click.echo("")
|
|
78
|
+
click.echo("Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# publicmcp serve
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
@cli.command()
|
|
86
|
+
@click.option(
|
|
87
|
+
"--business", "-b",
|
|
88
|
+
default="business.json",
|
|
89
|
+
show_default=True,
|
|
90
|
+
help="Path to your business.json file.",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--host",
|
|
94
|
+
default="0.0.0.0",
|
|
95
|
+
show_default=True,
|
|
96
|
+
help="Host to bind to.",
|
|
97
|
+
)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--port", "-p",
|
|
100
|
+
default=8000,
|
|
101
|
+
show_default=True,
|
|
102
|
+
type=int,
|
|
103
|
+
help="Port to listen on.",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--domain",
|
|
107
|
+
default="",
|
|
108
|
+
help=(
|
|
109
|
+
"Public domain name (e.g. mcp.yourdomain.com). "
|
|
110
|
+
"Used in the discovery endpoint URL. "
|
|
111
|
+
"Defaults to the domain in your business.json website field."
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--reload",
|
|
116
|
+
is_flag=True,
|
|
117
|
+
default=False,
|
|
118
|
+
help="Enable auto-reload on business.json changes (dev mode).",
|
|
119
|
+
)
|
|
120
|
+
def serve(
|
|
121
|
+
business: str,
|
|
122
|
+
host: str,
|
|
123
|
+
port: int,
|
|
124
|
+
domain: str,
|
|
125
|
+
reload: bool,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Start the PUBLICMCP server.
|
|
128
|
+
|
|
129
|
+
Reads business.json and starts a FastMCP server with 6 tools and 1 prompt.
|
|
130
|
+
Browsers are redirected to your publicmcp_page landing. AI agents get MCP.
|
|
131
|
+
|
|
132
|
+
\b
|
|
133
|
+
Endpoints:
|
|
134
|
+
/mcp — MCP (streamable-http)
|
|
135
|
+
/.well-known/publicmcp.json — discovery
|
|
136
|
+
/info — plain-text summary
|
|
137
|
+
"""
|
|
138
|
+
business_path = Path(business)
|
|
139
|
+
|
|
140
|
+
if not business_path.is_file():
|
|
141
|
+
click.echo(
|
|
142
|
+
f"Error: {business_path} not found.\n"
|
|
143
|
+
"Run 'publicmcp init' to create a starter business.json.",
|
|
144
|
+
err=True,
|
|
145
|
+
)
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
# Validate JSON before starting
|
|
149
|
+
try:
|
|
150
|
+
with open(business_path) as f:
|
|
151
|
+
biz = json.load(f)
|
|
152
|
+
except json.JSONDecodeError as exc:
|
|
153
|
+
click.echo(f"Error: Invalid JSON in {business_path}: {exc}", err=True)
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
|
|
156
|
+
# Resolve domain: CLI flag > website field > localhost
|
|
157
|
+
if not domain:
|
|
158
|
+
website = biz.get("website", "")
|
|
159
|
+
domain = (
|
|
160
|
+
website
|
|
161
|
+
.removeprefix("https://")
|
|
162
|
+
.removeprefix("http://")
|
|
163
|
+
.removeprefix("www.")
|
|
164
|
+
.rstrip("/")
|
|
165
|
+
or "localhost"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Spec compliance pre-check
|
|
169
|
+
missing = [
|
|
170
|
+
field for field in ("name", "services", "locations")
|
|
171
|
+
if not biz.get(field)
|
|
172
|
+
]
|
|
173
|
+
if missing:
|
|
174
|
+
click.echo(
|
|
175
|
+
f"Warning: business.json is missing required fields: {', '.join(missing)}\n"
|
|
176
|
+
"Server will start but may not pass spec compliance.",
|
|
177
|
+
err=True,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Import here to keep startup fast
|
|
181
|
+
from publicmcp.server import build_app
|
|
182
|
+
|
|
183
|
+
app = build_app(business_path=business_path, domain=domain)
|
|
184
|
+
|
|
185
|
+
click.echo(f"PUBLICMCP v{__version__} — {biz.get('name', 'unnamed')}")
|
|
186
|
+
click.echo(f" MCP endpoint : http://{host}:{port}/mcp")
|
|
187
|
+
click.echo(f" Discovery : http://{host}:{port}/.well-known/publicmcp.json")
|
|
188
|
+
click.echo(f" Info : http://{host}:{port}/info")
|
|
189
|
+
click.echo(f" Business : {business_path.resolve()}")
|
|
190
|
+
click.echo(f" Domain : {domain}")
|
|
191
|
+
click.echo("")
|
|
192
|
+
|
|
193
|
+
uvicorn.run(
|
|
194
|
+
app,
|
|
195
|
+
host=host,
|
|
196
|
+
port=port,
|
|
197
|
+
reload=reload,
|
|
198
|
+
log_level="info",
|
|
199
|
+
)
|
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""
|
|
2
|
+
publicmcp/server.py — PUBLICMCP v0.2 single-tenant FastMCP server.
|
|
3
|
+
|
|
4
|
+
Reads one business.json file and exposes 6 tools + 1 prompt over MCP.
|
|
5
|
+
Configured via build_app(business_path, domain) before calling uvicorn.run().
|
|
6
|
+
|
|
7
|
+
Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import JSONResponse, PlainTextResponse, RedirectResponse
|
|
19
|
+
from starlette.routing import Mount, Route
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Module-level config — set once by build_app() before uvicorn starts.
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_business_path: Path = Path("business.json")
|
|
27
|
+
_domain: str = "localhost"
|
|
28
|
+
|
|
29
|
+
_BROWSER_TOKENS = ("mozilla", "chrome", "safari", "firefox", "msie", "trident", "edge")
|
|
30
|
+
_PASSTHROUGH_PATHS = frozenset(["/.well-known/publicmcp.json", "/", "/info"])
|
|
31
|
+
|
|
32
|
+
_VERTICAL_KEYWORDS: dict[str, list[str]] = {
|
|
33
|
+
"functional_medicine": [
|
|
34
|
+
"functional medicine", "integrative medicine", "naturopath",
|
|
35
|
+
"holistic", "acupuncture", "wellness clinic",
|
|
36
|
+
],
|
|
37
|
+
"therapy_practices": [
|
|
38
|
+
"therapy", "therapist", "counseling", "counselor",
|
|
39
|
+
"mental health", "psychologist", "psychiatrist", "behavioral health",
|
|
40
|
+
],
|
|
41
|
+
"vacation_rentals": [
|
|
42
|
+
"vacation rental", "airbnb", "vrbo", "short term rental",
|
|
43
|
+
"short-term rental", "cabin rental", "lodge", "retreat center",
|
|
44
|
+
],
|
|
45
|
+
"civil_engineering": [
|
|
46
|
+
"civil engineering", "engineering firm", "infrastructure",
|
|
47
|
+
"municipal services", "surveying",
|
|
48
|
+
],
|
|
49
|
+
"real_estate": [
|
|
50
|
+
"real estate", "realtor", "realty", "real estate agent",
|
|
51
|
+
"real estate broker", "property management", "property manager",
|
|
52
|
+
],
|
|
53
|
+
"healthcare": [
|
|
54
|
+
"healthcare", "medical", "clinic", "hospital", "doctor", "dental",
|
|
55
|
+
"dentist", "physician", "pediatric", "chiropractic", "optometry",
|
|
56
|
+
"urgent care", "family medicine",
|
|
57
|
+
],
|
|
58
|
+
"construction": [
|
|
59
|
+
"construction", "contractor", "general contractor", "builder",
|
|
60
|
+
"plumber", "plumbing", "electrician", "electrical", "hvac",
|
|
61
|
+
"roofing", "concrete", "masonry", "landscaping", "excavation",
|
|
62
|
+
"remodeling", "home improvement",
|
|
63
|
+
],
|
|
64
|
+
"ecommerce": [
|
|
65
|
+
"ecommerce", "e-commerce", "online store", "shopify",
|
|
66
|
+
"retail", "boutique", "product shop",
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Business data loader
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _load_business() -> dict:
|
|
76
|
+
try:
|
|
77
|
+
with open(_business_path) as f:
|
|
78
|
+
return json.load(f)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"business.json not found at: {_business_path}\n"
|
|
82
|
+
"Run 'publicmcp init' to create one, or pass --business <path>."
|
|
83
|
+
) from None
|
|
84
|
+
except json.JSONDecodeError as exc:
|
|
85
|
+
raise RuntimeError(f"Invalid JSON in {_business_path}: {exc}") from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_city(market_str: str) -> str:
|
|
89
|
+
parts = market_str.split()
|
|
90
|
+
return " ".join(parts[:-1]).lower()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Browser redirect middleware — browsers go to the publicmcp_page landing
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
class _BrowserRedirectMiddleware(BaseHTTPMiddleware):
|
|
98
|
+
async def dispatch(self, request: Request, call_next):
|
|
99
|
+
ua = request.headers.get("user-agent", "").lower()
|
|
100
|
+
accept = request.headers.get("accept", "").lower()
|
|
101
|
+
is_browser = any(token in ua for token in _BROWSER_TOKENS)
|
|
102
|
+
wants_json = "application/json" in accept
|
|
103
|
+
in_passthrough = request.url.path in _PASSTHROUGH_PATHS
|
|
104
|
+
if is_browser and not wants_json and not in_passthrough:
|
|
105
|
+
biz = _load_business()
|
|
106
|
+
landing = biz.get(
|
|
107
|
+
"publicmcp_page",
|
|
108
|
+
f"https://www.{_domain.removeprefix('mcp.')}",
|
|
109
|
+
)
|
|
110
|
+
return RedirectResponse(landing, status_code=301)
|
|
111
|
+
return await call_next(request)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# FastMCP instance
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
mcp = FastMCP("publicmcp-server")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Prompt: sales_workflow
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
@mcp.prompt()
|
|
126
|
+
def sales_workflow() -> str:
|
|
127
|
+
"""
|
|
128
|
+
Workflow guide for qualifying a prospect using this PUBLICMCP server.
|
|
129
|
+
Load at session start to ensure correct tool chaining order.
|
|
130
|
+
"""
|
|
131
|
+
return """\
|
|
132
|
+
You are a consultative sales assistant connected to a PUBLICMCP-compliant server.
|
|
133
|
+
|
|
134
|
+
## Tool Chaining Order
|
|
135
|
+
|
|
136
|
+
1. get_info()
|
|
137
|
+
Confirm you are connected to the correct business before calling anything else.
|
|
138
|
+
|
|
139
|
+
2. qualify_prospect(description="<prospect description>")
|
|
140
|
+
Pass a brief description including industry, location, and current website
|
|
141
|
+
status if known. Returns fit_score, detected_vertical, recommended_service,
|
|
142
|
+
and discovery questions. Start here for prospect qualification.
|
|
143
|
+
Note: only available on servers with primary_markets data configured.
|
|
144
|
+
|
|
145
|
+
3. get_sales_context(vertical="<detected_vertical>")
|
|
146
|
+
Call after qualify_prospect when fit_score is "strong" or "possible".
|
|
147
|
+
Returns full objection handling, pain points, and ideal client profile.
|
|
148
|
+
Note: only available on servers with sales_agent data configured.
|
|
149
|
+
|
|
150
|
+
4. get_services(category="design" or "marketing")
|
|
151
|
+
Call when the prospect asks about specific services or pricing.
|
|
152
|
+
Never fabricate pricing — only quote what this tool returns.
|
|
153
|
+
|
|
154
|
+
5. get_location(market="<city>")
|
|
155
|
+
Confirm the business serves the prospect's market if location is ambiguous.
|
|
156
|
+
|
|
157
|
+
6. get_portfolio(market="<city>", category="<type>")
|
|
158
|
+
Use when the prospect wants to see relevant past work.
|
|
159
|
+
|
|
160
|
+
## Rules
|
|
161
|
+
|
|
162
|
+
- Always confirm location early — not all businesses serve all markets.
|
|
163
|
+
- If qualify_prospect returns fit_score "poor", ask clarifying questions.
|
|
164
|
+
- Never invent services, pricing, or portfolio items not returned by tools.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Tool: get_info
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
@mcp.tool()
|
|
173
|
+
def get_info() -> str:
|
|
174
|
+
"""
|
|
175
|
+
Return core identity information about this business (PUBLICMCP v0.2).
|
|
176
|
+
|
|
177
|
+
Includes business name, tagline, description, founding year, website,
|
|
178
|
+
business type, legal name, logo, price range, contact details,
|
|
179
|
+
structured opening hours, and social profiles.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
JSON object conforming to PUBLICMCP v0.2 get_info schema.
|
|
183
|
+
"""
|
|
184
|
+
biz = _load_business()
|
|
185
|
+
|
|
186
|
+
raw_contact = biz.get("contact", {})
|
|
187
|
+
contact = {k: v for k, v in raw_contact.items() if k != "phone"}
|
|
188
|
+
if "phone" in raw_contact and "telephone" not in contact:
|
|
189
|
+
contact["telephone"] = raw_contact["phone"]
|
|
190
|
+
|
|
191
|
+
social_profiles = biz.get("social_profiles") or biz.get("social", {})
|
|
192
|
+
|
|
193
|
+
return json.dumps(
|
|
194
|
+
{
|
|
195
|
+
"publicmcp_version": "0.2",
|
|
196
|
+
"name": biz.get("name", ""),
|
|
197
|
+
"tagline": biz.get("tagline", ""),
|
|
198
|
+
"description": biz.get("description", ""),
|
|
199
|
+
"founded": biz.get("founded"),
|
|
200
|
+
"founder": biz.get("founder", ""),
|
|
201
|
+
"co_founder": biz.get("co_founder", ""),
|
|
202
|
+
"website": biz.get("website", ""),
|
|
203
|
+
"business_type": biz.get("business_type", []),
|
|
204
|
+
"legal_name": biz.get("legal_name", ""),
|
|
205
|
+
"logo": biz.get("logo", ""),
|
|
206
|
+
"price_range": biz.get("price_range", ""),
|
|
207
|
+
"contact": contact,
|
|
208
|
+
"opening_hours": biz.get("opening_hours", []),
|
|
209
|
+
"social_profiles": social_profiles,
|
|
210
|
+
},
|
|
211
|
+
indent=2,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Tool: get_services
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
@mcp.tool()
|
|
220
|
+
def get_services(category: str = "") -> str:
|
|
221
|
+
"""
|
|
222
|
+
Return services offered by this business.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
category: Optional filter. Leave empty to return all services.
|
|
226
|
+
Common values: 'design', 'marketing', 'hair', 'color',
|
|
227
|
+
'styling', 'extensions' — depends on the business.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
JSON array of service objects with name, category, description,
|
|
231
|
+
highlights, pricing, and timeline.
|
|
232
|
+
"""
|
|
233
|
+
biz = _load_business()
|
|
234
|
+
services = biz.get("services", [])
|
|
235
|
+
|
|
236
|
+
if category:
|
|
237
|
+
services = [s for s in services if s.get("category", "") == category.lower()]
|
|
238
|
+
|
|
239
|
+
if not services:
|
|
240
|
+
return json.dumps({"error": f"No services found for category: {category!r}"})
|
|
241
|
+
|
|
242
|
+
return json.dumps(services, indent=2)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Tool: get_location
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
@mcp.tool()
|
|
250
|
+
def get_location(market: str = "") -> str:
|
|
251
|
+
"""
|
|
252
|
+
Return geographic presence and service coverage (PUBLICMCP v0.2).
|
|
253
|
+
|
|
254
|
+
Includes headquarters, geo coordinates, service radius, remote capability,
|
|
255
|
+
office locations, and full service area list.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
market: Optional city filter (e.g. 'Boise', 'Spokane').
|
|
259
|
+
Leave empty to return all offices and service areas.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
JSON object conforming to PUBLICMCP v0.2 get_location schema.
|
|
263
|
+
"""
|
|
264
|
+
biz = _load_business()
|
|
265
|
+
locations = biz.get("locations", [])
|
|
266
|
+
service_areas = biz.get("service_areas", [])
|
|
267
|
+
|
|
268
|
+
if market:
|
|
269
|
+
locations = [
|
|
270
|
+
loc for loc in locations
|
|
271
|
+
if (
|
|
272
|
+
market.lower() in loc.get("city", "").lower()
|
|
273
|
+
or market.lower() in loc.get("market", "").lower()
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
headquarters = biz.get("headquarters")
|
|
278
|
+
if not headquarters:
|
|
279
|
+
primary = next(
|
|
280
|
+
(loc for loc in biz.get("locations", []) if loc.get("primary")),
|
|
281
|
+
biz.get("locations", [{}])[0] if biz.get("locations") else {},
|
|
282
|
+
)
|
|
283
|
+
city = primary.get("city", "")
|
|
284
|
+
state = primary.get("state", "")
|
|
285
|
+
headquarters = f"{city}, {state}".strip(", ") if city or state else ""
|
|
286
|
+
|
|
287
|
+
return json.dumps(
|
|
288
|
+
{
|
|
289
|
+
"headquarters": headquarters,
|
|
290
|
+
"geo": biz.get("geo"),
|
|
291
|
+
"geo_radius": biz.get("geo_radius"),
|
|
292
|
+
"remote_capable": biz.get("remote_capable", True),
|
|
293
|
+
"offices": locations,
|
|
294
|
+
"service_areas": service_areas,
|
|
295
|
+
},
|
|
296
|
+
indent=2,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Tool: get_portfolio
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
@mcp.tool()
|
|
305
|
+
def get_portfolio(market: str = "", category: str = "") -> str:
|
|
306
|
+
"""
|
|
307
|
+
Return portfolio highlights from this business.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
market: Optional market filter (e.g. 'Boise', 'Meridian').
|
|
311
|
+
Leave empty for all markets.
|
|
312
|
+
category: Optional category filter (e.g. 'web design', 'color').
|
|
313
|
+
Leave empty for all categories.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
JSON array of portfolio items with title, description, category,
|
|
317
|
+
and markets list.
|
|
318
|
+
"""
|
|
319
|
+
biz = _load_business()
|
|
320
|
+
items = biz.get("portfolio", [])
|
|
321
|
+
|
|
322
|
+
if market:
|
|
323
|
+
items = [
|
|
324
|
+
item for item in items
|
|
325
|
+
if any(market.lower() in m.lower() for m in item.get("markets", []))
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
if category:
|
|
329
|
+
items = [
|
|
330
|
+
item for item in items
|
|
331
|
+
if category.lower() in item.get("category", "").lower()
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
if not items:
|
|
335
|
+
return json.dumps({"message": "No portfolio items match those filters."})
|
|
336
|
+
|
|
337
|
+
return json.dumps(items, indent=2)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Tool: get_sales_context (managed-service extension)
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
@mcp.tool()
|
|
345
|
+
def get_sales_context(vertical: str = "") -> str:
|
|
346
|
+
"""
|
|
347
|
+
Return consultative sales context for a specific business vertical.
|
|
348
|
+
|
|
349
|
+
Use this after qualify_prospect returns a detected_vertical. Returns pain
|
|
350
|
+
points, objection handling, competitive positioning, ideal client profile,
|
|
351
|
+
qualifying questions, and social proof for that vertical.
|
|
352
|
+
|
|
353
|
+
Only available on servers with sales_agent data configured in business.json.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
vertical: The prospect's industry vertical. Common values: real_estate,
|
|
357
|
+
healthcare, construction, functional_medicine, vacation_rentals,
|
|
358
|
+
ecommerce, therapy_practices, civil_engineering.
|
|
359
|
+
Leave empty for general context.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
JSON object with pain_point, objection_responses,
|
|
363
|
+
competitive_positioning, ideal_client_profile, discovery_questions,
|
|
364
|
+
qualifying_questions, and social_proof.
|
|
365
|
+
"""
|
|
366
|
+
biz = _load_business()
|
|
367
|
+
agent = biz.get("sales_agent")
|
|
368
|
+
|
|
369
|
+
if not agent:
|
|
370
|
+
return json.dumps({
|
|
371
|
+
"message": (
|
|
372
|
+
"Sales context is not configured for this server. "
|
|
373
|
+
"Add a 'sales_agent' block to your business.json to enable it."
|
|
374
|
+
)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
profiles = biz.get("ideal_client_profiles", [])
|
|
378
|
+
qualifying = biz.get("qualifying_questions", {})
|
|
379
|
+
|
|
380
|
+
vertical_key = (
|
|
381
|
+
vertical.lower().replace(" ", "_").replace("-", "_") if vertical else ""
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
pain_point = None
|
|
385
|
+
profile = None
|
|
386
|
+
|
|
387
|
+
if vertical_key:
|
|
388
|
+
pain_point = agent.get("pain_points_by_vertical", {}).get(vertical_key)
|
|
389
|
+
profile = next(
|
|
390
|
+
(
|
|
391
|
+
p for p in profiles
|
|
392
|
+
if vertical_key in p["persona"].lower().replace(" ", "_")
|
|
393
|
+
or any(
|
|
394
|
+
vertical_key in e.lower().replace(" ", "_")
|
|
395
|
+
for e in p.get("examples", [])
|
|
396
|
+
)
|
|
397
|
+
),
|
|
398
|
+
None,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
result: dict = {}
|
|
402
|
+
|
|
403
|
+
if pain_point:
|
|
404
|
+
result["pain_point"] = pain_point
|
|
405
|
+
elif vertical_key:
|
|
406
|
+
result["pain_point"] = (
|
|
407
|
+
f"No specific pain point data for '{vertical}'. "
|
|
408
|
+
"Use general positioning from competitive_positioning below."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
result["objection_responses"] = agent.get("objections", {})
|
|
412
|
+
result["competitive_positioning"] = agent.get("competitive_positioning", {})
|
|
413
|
+
result["social_proof"] = agent.get("social_proof", {})
|
|
414
|
+
result["discovery_questions"] = agent.get("discovery_questions", [])
|
|
415
|
+
result["qualifying_questions"] = qualifying.get("questions", [])
|
|
416
|
+
|
|
417
|
+
if profile:
|
|
418
|
+
result["ideal_client_profile"] = profile
|
|
419
|
+
else:
|
|
420
|
+
result["ideal_client_profiles"] = profiles
|
|
421
|
+
|
|
422
|
+
result["project_fit"] = biz.get("project_fit", {})
|
|
423
|
+
|
|
424
|
+
return json.dumps(result, indent=2)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# Tool: qualify_prospect (managed-service extension)
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
@mcp.tool()
|
|
432
|
+
def qualify_prospect(description: str) -> str:
|
|
433
|
+
"""
|
|
434
|
+
Assess whether a prospect is a good fit for this business.
|
|
435
|
+
|
|
436
|
+
Analyzes a free-text prospect description to detect vertical, location,
|
|
437
|
+
and intent signals. Returns a structured fit assessment with recommended
|
|
438
|
+
service and discovery questions.
|
|
439
|
+
|
|
440
|
+
Only available on servers with primary_markets data in business.json.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
description: Free-text description of the prospect. Include location,
|
|
444
|
+
industry, current situation, and goals if known.
|
|
445
|
+
Example: "pediatric dentist in Spokane, currently on Wix,
|
|
446
|
+
4-person practice, wants more new patient leads".
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
JSON object with fit_score (strong/possible/poor), fit_reason,
|
|
450
|
+
detected_vertical, location_match, matched_market,
|
|
451
|
+
recommended_service, pricing_hint, discovery_questions,
|
|
452
|
+
vertical_pain_point (if available), and next_step.
|
|
453
|
+
"""
|
|
454
|
+
if not description or not description.strip():
|
|
455
|
+
return json.dumps({"error": "description is required."})
|
|
456
|
+
|
|
457
|
+
biz = _load_business()
|
|
458
|
+
primary_markets: list = biz.get("primary_markets") or []
|
|
459
|
+
|
|
460
|
+
if not primary_markets:
|
|
461
|
+
return json.dumps({
|
|
462
|
+
"message": (
|
|
463
|
+
"qualify_prospect requires 'primary_markets' in business.json. "
|
|
464
|
+
"Add a list of your target markets to enable this tool."
|
|
465
|
+
)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
desc = description.lower()
|
|
469
|
+
service_areas: list = biz.get("service_areas") or []
|
|
470
|
+
|
|
471
|
+
detected_vertical = None
|
|
472
|
+
for vertical, keywords in _VERTICAL_KEYWORDS.items():
|
|
473
|
+
if any(kw in desc for kw in keywords):
|
|
474
|
+
detected_vertical = vertical
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
location_match = "out_of_market"
|
|
478
|
+
matched_market = None
|
|
479
|
+
|
|
480
|
+
for market in primary_markets:
|
|
481
|
+
if _extract_city(market) in desc:
|
|
482
|
+
location_match = "primary_market"
|
|
483
|
+
matched_market = market
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
if location_match == "out_of_market":
|
|
487
|
+
for area in service_areas:
|
|
488
|
+
city = _extract_city(area)
|
|
489
|
+
if len(city) > 3 and city in desc:
|
|
490
|
+
location_match = "service_area"
|
|
491
|
+
matched_market = area
|
|
492
|
+
break
|
|
493
|
+
|
|
494
|
+
has_no_website = any(s in desc for s in [
|
|
495
|
+
"no website", "don't have a website", "dont have a website",
|
|
496
|
+
"need a website", "starting from scratch", "never had a website",
|
|
497
|
+
])
|
|
498
|
+
needs_redesign = any(s in desc for s in [
|
|
499
|
+
"redesign", "outdated", "old website", "rebuild", "wix",
|
|
500
|
+
"squarespace", "godaddy website", "diy site", "not working",
|
|
501
|
+
"needs work", "looks bad",
|
|
502
|
+
])
|
|
503
|
+
wants_seo = any(s in desc for s in [
|
|
504
|
+
"seo", "ranking", "rank on google", "google search",
|
|
505
|
+
"organic traffic", "search traffic", "show up on google", "leads",
|
|
506
|
+
])
|
|
507
|
+
|
|
508
|
+
in_service_area = location_match in ("primary_market", "service_area")
|
|
509
|
+
has_vertical = detected_vertical is not None
|
|
510
|
+
|
|
511
|
+
if in_service_area and has_vertical:
|
|
512
|
+
fit_score = "strong"
|
|
513
|
+
fit_reason = (
|
|
514
|
+
f"Served market ({matched_market}) with clear vertical match "
|
|
515
|
+
f"({detected_vertical.replace('_', ' ')})."
|
|
516
|
+
)
|
|
517
|
+
elif in_service_area:
|
|
518
|
+
fit_score = "possible"
|
|
519
|
+
fit_reason = (
|
|
520
|
+
f"Served market ({matched_market}) confirmed but industry vertical "
|
|
521
|
+
"is unclear — ask about their business type."
|
|
522
|
+
)
|
|
523
|
+
elif has_vertical:
|
|
524
|
+
fit_score = "possible"
|
|
525
|
+
fit_reason = (
|
|
526
|
+
f"Vertical match ({detected_vertical.replace('_', ' ')}) detected "
|
|
527
|
+
"but location not confirmed in service area — verify their market."
|
|
528
|
+
)
|
|
529
|
+
else:
|
|
530
|
+
fit_score = "poor"
|
|
531
|
+
fit_reason = (
|
|
532
|
+
"Neither location nor vertical could be confirmed from the "
|
|
533
|
+
"description. More detail needed before routing to a quote."
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
services = biz.get("services", [])
|
|
537
|
+
default_service = services[0]["name"] if services else "Core Service"
|
|
538
|
+
|
|
539
|
+
if has_no_website:
|
|
540
|
+
recommended_service = f"{default_service} — New Build"
|
|
541
|
+
pricing_hint = "Contact for quote"
|
|
542
|
+
elif needs_redesign and wants_seo:
|
|
543
|
+
recommended_service = f"{default_service} + SEO / Growth Package"
|
|
544
|
+
pricing_hint = "Contact for quote"
|
|
545
|
+
elif needs_redesign:
|
|
546
|
+
recommended_service = f"{default_service} — Redesign"
|
|
547
|
+
pricing_hint = "Contact for quote"
|
|
548
|
+
elif wants_seo:
|
|
549
|
+
recommended_service = "SEO / Growth Service"
|
|
550
|
+
pricing_hint = "Contact for quote"
|
|
551
|
+
else:
|
|
552
|
+
recommended_service = default_service
|
|
553
|
+
pricing_hint = "Contact for quote"
|
|
554
|
+
|
|
555
|
+
if services:
|
|
556
|
+
pricing = services[0].get("pricing", {})
|
|
557
|
+
if isinstance(pricing, dict) and pricing.get("project_range"):
|
|
558
|
+
pricing_hint = pricing["project_range"]
|
|
559
|
+
|
|
560
|
+
questions = biz.get("qualifying_questions", {}).get("questions", [])
|
|
561
|
+
discovery_questions = [q["question"] for q in questions[:3]]
|
|
562
|
+
|
|
563
|
+
contact_url = biz.get("contact", {}).get("quote_url") or biz.get("website", "")
|
|
564
|
+
if fit_score == "strong":
|
|
565
|
+
next_step = f"Route to a project quote: {contact_url}"
|
|
566
|
+
elif fit_score == "possible":
|
|
567
|
+
next_step = (
|
|
568
|
+
"Gather more details on location and goals, then route to a quote "
|
|
569
|
+
"if confirmed in service area."
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
next_step = (
|
|
573
|
+
"Clarify location and industry before proceeding. "
|
|
574
|
+
"Call get_location() to review service areas."
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
result: dict = {
|
|
578
|
+
"fit_score": fit_score,
|
|
579
|
+
"fit_reason": fit_reason,
|
|
580
|
+
"detected_vertical": (
|
|
581
|
+
detected_vertical
|
|
582
|
+
or "unknown — include industry in description for better results"
|
|
583
|
+
),
|
|
584
|
+
"location_match": location_match,
|
|
585
|
+
"matched_market": matched_market,
|
|
586
|
+
"recommended_service": recommended_service,
|
|
587
|
+
"pricing_hint": pricing_hint,
|
|
588
|
+
"discovery_questions": discovery_questions,
|
|
589
|
+
"next_step": next_step,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if detected_vertical:
|
|
593
|
+
pain_point = (
|
|
594
|
+
biz.get("sales_agent", {})
|
|
595
|
+
.get("pain_points_by_vertical", {})
|
|
596
|
+
.get(detected_vertical)
|
|
597
|
+
)
|
|
598
|
+
if pain_point:
|
|
599
|
+
result["vertical_pain_point"] = pain_point
|
|
600
|
+
|
|
601
|
+
return json.dumps(result, indent=2)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
# Static endpoints
|
|
606
|
+
# ---------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
async def _info_handler(request: Request) -> PlainTextResponse:
|
|
609
|
+
biz = _load_business()
|
|
610
|
+
services_list = biz.get("services", [])
|
|
611
|
+
service_names = ", ".join(s["name"] for s in services_list[:4])
|
|
612
|
+
text = (
|
|
613
|
+
f"Business: {biz.get('name')}\n"
|
|
614
|
+
f"Tagline: {biz.get('tagline')}\n"
|
|
615
|
+
f"Website: {biz.get('website')}\n"
|
|
616
|
+
f"\nServices: {service_names}\n"
|
|
617
|
+
f"\nTools: get_info, get_services, get_location, get_portfolio, "
|
|
618
|
+
f"get_sales_context, qualify_prospect\n"
|
|
619
|
+
f"Prompt: sales_workflow\n"
|
|
620
|
+
f"\nMCP endpoint: https://{_domain}/mcp\n"
|
|
621
|
+
f"Discovery: https://{_domain}/.well-known/publicmcp.json\n"
|
|
622
|
+
f"Spec: https://github.com/joeprovence/publicmcp/blob/main/SPEC.md\n"
|
|
623
|
+
)
|
|
624
|
+
return PlainTextResponse(text, headers={"Access-Control-Allow-Origin": "*"})
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _discovery_payload() -> dict:
|
|
628
|
+
biz = _load_business()
|
|
629
|
+
has_sales = bool(biz.get("sales_agent"))
|
|
630
|
+
has_markets = bool(biz.get("primary_markets"))
|
|
631
|
+
|
|
632
|
+
tools_list = [
|
|
633
|
+
{"name": "get_info", "version": "0.2", "required": True},
|
|
634
|
+
{"name": "get_services", "version": "0.2", "required": True},
|
|
635
|
+
{"name": "get_location", "version": "0.2", "required": True},
|
|
636
|
+
{"name": "get_portfolio", "version": "0.2", "required": False},
|
|
637
|
+
]
|
|
638
|
+
if has_sales:
|
|
639
|
+
tools_list.append({"name": "get_sales_context", "version": "0.2", "required": False})
|
|
640
|
+
if has_markets:
|
|
641
|
+
tools_list.append({"name": "qualify_prospect", "version": "0.2", "required": False})
|
|
642
|
+
|
|
643
|
+
tool_guide = {
|
|
644
|
+
"get_info": "Core identity — name, description, founders, contact",
|
|
645
|
+
"get_services": "Service list with pricing, timeline, and highlights",
|
|
646
|
+
"get_location": "Office locations and service areas",
|
|
647
|
+
"get_portfolio": "Portfolio highlights by market and category",
|
|
648
|
+
}
|
|
649
|
+
if has_sales:
|
|
650
|
+
tool_guide["get_sales_context"] = (
|
|
651
|
+
"Consultative sales brief by vertical — pain points, objections, "
|
|
652
|
+
"ideal client profile, qualifying questions"
|
|
653
|
+
)
|
|
654
|
+
if has_markets:
|
|
655
|
+
tool_guide["qualify_prospect"] = (
|
|
656
|
+
"Pass a free-text prospect description to receive fit_score, "
|
|
657
|
+
"detected_vertical, and recommended_service."
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
"publicmcp_version": "0.2",
|
|
662
|
+
"auth": "none",
|
|
663
|
+
"note": (
|
|
664
|
+
"This is a Model Context Protocol (MCP) server that lets AI agents "
|
|
665
|
+
"query structured business data. Use the tools below to learn about "
|
|
666
|
+
f"{biz.get('name', 'this business')} and assess fit for a prospect."
|
|
667
|
+
),
|
|
668
|
+
"name": f"{biz.get('name', _domain).lower().replace(' ', '-')}-publicmcp",
|
|
669
|
+
"business": f"{biz.get('name')} — {biz.get('tagline', '')}",
|
|
670
|
+
"endpoint": f"https://{_domain}/mcp",
|
|
671
|
+
"prompts": {
|
|
672
|
+
"sales_workflow": (
|
|
673
|
+
"Load at session start — defines correct tool chaining order "
|
|
674
|
+
"for prospect qualification."
|
|
675
|
+
),
|
|
676
|
+
},
|
|
677
|
+
"tools": tools_list,
|
|
678
|
+
"tool_guide": tool_guide,
|
|
679
|
+
"spec": "https://github.com/joeprovence/publicmcp/blob/main/SPEC.md",
|
|
680
|
+
"registry": (
|
|
681
|
+
"https://github.com/joeprovence/publicmcp"
|
|
682
|
+
"/blob/main/registry/servers.yaml"
|
|
683
|
+
),
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
_CORS_HEADERS = {
|
|
688
|
+
"Access-Control-Allow-Origin": "*",
|
|
689
|
+
"Access-Control-Allow-Methods": "GET",
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
async def _well_known_handler(request: Request) -> JSONResponse:
|
|
694
|
+
return JSONResponse(_discovery_payload(), headers=_CORS_HEADERS)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
async def _root_handler(request: Request) -> JSONResponse:
|
|
698
|
+
return JSONResponse(_discovery_payload(), headers=_CORS_HEADERS)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# ---------------------------------------------------------------------------
|
|
702
|
+
# App factory — called by cli.py before uvicorn.run()
|
|
703
|
+
# ---------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
def build_app(business_path: Path, domain: str = "localhost") -> Starlette:
|
|
706
|
+
global _business_path, _domain
|
|
707
|
+
_business_path = Path(business_path).resolve()
|
|
708
|
+
_domain = domain
|
|
709
|
+
|
|
710
|
+
mcp_app = mcp.http_app(transport="streamable-http")
|
|
711
|
+
app = Starlette(
|
|
712
|
+
lifespan=mcp_app.lifespan,
|
|
713
|
+
routes=[
|
|
714
|
+
Route("/", _root_handler, methods=["GET"]),
|
|
715
|
+
Route("/info", _info_handler, methods=["GET"]),
|
|
716
|
+
Route("/.well-known/publicmcp.json", _well_known_handler, methods=["GET"]),
|
|
717
|
+
Mount("/", app=mcp_app),
|
|
718
|
+
],
|
|
719
|
+
)
|
|
720
|
+
app.add_middleware(_BrowserRedirectMiddleware)
|
|
721
|
+
return app
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Your Business Name",
|
|
3
|
+
"tagline": "Your tagline here",
|
|
4
|
+
"description": "A brief description of what your business does and who it serves.",
|
|
5
|
+
"founded": 2020,
|
|
6
|
+
"founder": "Founder Name",
|
|
7
|
+
"website": "https://www.yourdomain.com",
|
|
8
|
+
"publicmcp_page": "https://www.yourdomain.com/publicmcp",
|
|
9
|
+
"business_type": ["LocalBusiness"],
|
|
10
|
+
"legal_name": "Your Business LLC",
|
|
11
|
+
"logo": "https://www.yourdomain.com/logo.png",
|
|
12
|
+
"price_range": "$$",
|
|
13
|
+
"contact": {
|
|
14
|
+
"email": "hello@yourdomain.com",
|
|
15
|
+
"telephone": "",
|
|
16
|
+
"quote_url": "https://www.yourdomain.com/contact"
|
|
17
|
+
},
|
|
18
|
+
"opening_hours": [
|
|
19
|
+
{
|
|
20
|
+
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
|
21
|
+
"opens": "09:00",
|
|
22
|
+
"closes": "17:00"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"social_profiles": {
|
|
26
|
+
"twitter": "",
|
|
27
|
+
"facebook": "",
|
|
28
|
+
"linkedin": ""
|
|
29
|
+
},
|
|
30
|
+
"headquarters": "Your City, ST",
|
|
31
|
+
"geo": {
|
|
32
|
+
"lat": 0.0,
|
|
33
|
+
"lng": 0.0
|
|
34
|
+
},
|
|
35
|
+
"remote_capable": true,
|
|
36
|
+
"locations": [
|
|
37
|
+
{
|
|
38
|
+
"city": "Your City",
|
|
39
|
+
"state": "ST",
|
|
40
|
+
"market": "Your City ST",
|
|
41
|
+
"primary": true
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"service_areas": [
|
|
45
|
+
"Your City ST"
|
|
46
|
+
],
|
|
47
|
+
"services": [
|
|
48
|
+
{
|
|
49
|
+
"name": "Your Primary Service",
|
|
50
|
+
"category": "primary",
|
|
51
|
+
"description": "Describe what you offer and who it is for.",
|
|
52
|
+
"highlights": [
|
|
53
|
+
"Key benefit 1",
|
|
54
|
+
"Key benefit 2",
|
|
55
|
+
"Key benefit 3"
|
|
56
|
+
],
|
|
57
|
+
"pricing": {
|
|
58
|
+
"project_range": "Contact for quote"
|
|
59
|
+
},
|
|
60
|
+
"timeline": "2-4 weeks"
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"portfolio": [
|
|
64
|
+
{
|
|
65
|
+
"title": "Sample Project",
|
|
66
|
+
"description": "Brief description of a past project and its outcome.",
|
|
67
|
+
"category": "primary",
|
|
68
|
+
"markets": ["Your City ST"]
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"project_fit": {
|
|
72
|
+
"ideal_client": "Describe your ideal client here.",
|
|
73
|
+
"not_a_fit": "Describe who you do not serve here."
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "publicmcp"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Build a PUBLICMCP-compliant business identity MCP server in minutes"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
keywords = ["mcp", "ai", "business", "identity", "agents", "fastmcp"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: Apache Software License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"fastmcp>=3.2",
|
|
26
|
+
"uvicorn>=0.30",
|
|
27
|
+
"starlette>=0.40",
|
|
28
|
+
"click>=8.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://publicmcp.org"
|
|
33
|
+
Repository = "https://github.com/joeprovence/publicmcp"
|
|
34
|
+
Spec = "https://github.com/joeprovence/publicmcp/blob/main/SPEC.md"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
publicmcp = "publicmcp.cli:cli"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["publicmcp"]
|