Reply Pilot MCP
MCP is a Java package, cz.replypilot.be.mcp, inside reply-pilot-be.
It shares the BE process, port, configuration and existing business services.
There is no separate MCP module, container or Python runtime.
The official Java SDK
implements stateless Streamable HTTP at /mcp.
CompanyTools owns the tool definitions, validation and authorized execution.
Pippa exposes these definitions as OpenAI function tools and executes them through
the same HTTP endpoint as external MCP clients, using a personal Pippa bearer token.
Tools and permissions
| Tool | Existing Java service | Effect |
|---|---|---|
check_companies |
PippaScoutingService, SearchService, PippaOnboardingService.matches |
Batch company search, exact duplicate/CME checks and durable scouting rejections; no selected company required. |
save_scout_candidates |
PippaScoutingService |
Save sourced candidates and search membership, with automatic verification; never create a company or ticket. |
decide_scout_candidate |
PippaScoutingService |
Explicit permanent rejection/reopening or search-only skip/restore, with a mandatory reason. |
get_company |
ReadModelService.loadCompanyDetail |
Read the full current company, contacts, related people with their company-specific job titles and role descriptions, and explicit CME registration status. |
list_assignment_users |
AppStateService.listAssignableUsers |
Read eligible application users and their IDs/names/emails; no company argument, also available before onboarding creates a company. Requires company-write permission. |
reassign_company |
TaskMutationService.reassignCompany |
Set the default email sales user and reassign open company tickets using assignee_user_id. Closed Jira statuses/categories are skipped; failures are returned explicitly. |
get_company_closure |
CompanyClosureService.options, JiraProxyService |
Read company tickets, live Jira states and current Supplier Onboarding Result reasons under Nezaveden. |
close_company_opportunity |
CompanyClosureService.close, JiraProxyService |
Save the mandatory onboarding result and user explanation, then close all open company tickets. |
create_company_person |
PartyMutationService.createPerson |
Create one person linked to the current company, with supplied email/phone contacts. |
update_company_person_role |
PartyMutationService.updateCompanyPersonRole |
Update the job title and role description on an existing active person–company relationship. |
add_company_contact |
PartyMutationService.addPartyContact |
Add a shared email/phone directly to the current company. |
add_company_note |
PartyMutationService.addPartyNote |
Create a company note in Reply Pilot, with the authenticated user as author; no Jira comment or status change. |
list_email_attachments |
PippaContextService |
List existing company email/Jira file references usable by send_email. |
send_email |
McpEmailService, EmailSendService, TaskMutationService |
Send from the shared mailbox, import the canonical message, and create/update its communication ticket. Durable operation key prevents replay. |
Every call reloads the actor's current permissions. Company tools require full company-profile
access. Scouting tools require company.write or company.write_assigned; scouting writes additionally require the current conversation's author. They do not grant access to private company profiles. Contact and note writes additionally require the same company collaboration rights as the
existing forms. Opportunity closure requires company-write scope and scoped
task access, plus the actor's Jira OAuth grant; collaboration alone is insufficient.
Full read access alone does not grant write permission.
The actor comes only from the authenticated bearer token. MCP tool arguments include
company_id, or scouting_conversation_id for scouting mutations; handlers enforce
the actor's access to that resource. Pippa injects these IDs from its trusted conversation
context, never from model-supplied arguments. check_companies needs neither ID.
Contacts are unverified and non-primary; the model cannot override these flags.
Existing business validation and duplicate-email checks remain in the mutation service.
get_company.cme_registration contains active_supplier, salespeople,
onboarding_blocked and a Czech reason. CmeOnboardingStatus derives this from
current company CME sources: DODAVATEL with missing_since IS NULL blocks
onboarding and initial outreach, even without an assigned salesperson. Historical
missing sources and OSLOVENI reservations do not count as active suppliers.
The existing profile still includes the full CME records. Explicitly requested
profile/contact enrichment remains allowed under the existing permissions.
Pippa configuration
Edit reply-pilot-be/conf/pippa.yaml and restart BE:
model: gpt-5.4
enabled_tools:
- web_search
- list_sources
- read_source
- read_history
- get_company
- list_assignment_users
- reassign_company
- list_email_attachments
- send_email
- get_company_closure
- close_company_opportunity
- create_company_person
- update_company_person_role
- add_company_contact
- add_company_note
- check_companies
- save_scout_candidates
- decide_scout_candidate
instructions: |-
# Full production instructions are in the actual file.
Answer using sources; write contacts only on an explicit user request.
The file controls the model, developer instructions and allowed tools. The shipped
fallback is reply-pilot-be/default-conf/pippa.yaml. In the container these paths
are /app/conf/pippa.yaml and /app/default-conf/pippa.yaml; the read-only conf/
mount takes precedence. Deploy preserves server-owned conf/, so an existing
override must be maintained separately. Configuration is loaded once at startup;
missing/invalid configuration or unknown tool names fail startup.
When deploying the CME onboarding rule, company reassignment, company notes, opportunity closure, person-role updates or email tools, update the instructions
and enabled_tools in an existing server-owned conf/pippa.yaml from the shipped configuration and restart BE;
deploying a new fallback file alone does not replace that override.
An empty enabled_tools list disables every tool. Removing a tool prevents both
discovery and execution by name. The first four tools above are Pippa-specific
research/history tools; company and scouting tools are exposed over MCP.
Secrets remain in BE SOPS files. Pippa uses the existing BE OpenAI API key, URL
and timeout; its model no longer comes from OPENAI_MODEL_LEAD_IMPORT.
Pippa write behavior
On an explicit request such as “add contacts 1, 2 and 4”, the instructions require
Pippa to resolve those numbers against the actual previous response, check existing
contacts with get_company, and use exact known names and contact values. Ambiguous
selections require clarification. Tool success, IDs and profile links are returned
to the model; errors cannot be treated as confirmed writes. Email sending and its
communication-ticket workflow use only the dedicated send_email tool described below.
add_company_note accepts note_text (nonblank, at most 32,000 characters).
External MCP calls also supply company_id; Pippa supplies the company from its
trusted conversation scope. created_by_user_id comes only from the authenticated
bearer identity, never from model/client arguments. The existing note service
stores an activity and activity_note, returning activity_id, party_id,
the saved text and the company link. No schema or permission is added.
Czech poznámka, poznámka společnosti, komentář and komentář společnosti
mean a company note, including unaccented spelling. English comment and explicit
Jira-comment requests retain their Jira meaning. Closing an opportunity writes an
audit comment to Jira; it does not create a company note. A combined close-and-note
request saves the company note separately before closure. Pippa confirms the note
only from a successful result with activity_id, or an actual saved note: source,
never from an older assistant claim or Jira comment. The tool is available in
company chat and attached onboarding (including after completion), but not scouting
or onboarding before company creation. It uses the existing Pippa write guard and
same-turn duplicate suppression. External calls are not idempotent; clients must
not automatically retry an uncertain write.
For person roles, get_company.profile.relatedPeople exposes recordId, jobTitle
(Pracovní pozice) and roleDescription (Popis role). Pippa reads these current
values before calling update_company_person_role with person_id, job_title
and role_description. Both text arguments are required and replace both fields;
Pippa preserves any value the user did not ask to change. An empty string clears
a field. Job titles are single-line text up to 255 characters; role descriptions
allow multiple lines up to 5,000 characters. The same validation and company
collaboration permission as the form apply. The person must already have an active
CONTACT_FOR relationship to the selected company; missing or ended relationships
are rejected. Roles at other companies and the person's identity remain unchanged.
After explicitly creating a new person, Pippa can set their role using the returned
party_id as person_id, after verifying the current company profile. This tool
is available in company conversations, external MCP and onboarding conversations
after the company has been created. The same applies to create_company_person
and add_company_contact. Pre-company onboarding and scouting cannot use these
mutations; approving the initial company form saves its selected contacts instead.
Deploy migration 0093 from the person-role feature before this backend version,
enable the tool in any server-owned conf/pippa.yaml, and restart BE.
For “Ukonči příležitost, společnost nemá dost produktů”, Pippa first calls
get_company_closure, maps the user's reason to a current Jira option (for example
Nezaveden → Maly sortiment), then calls close_company_opportunity with the
Supplier Onboarding task_id, reason_option_id and original reason text.
IDs come from current metadata, not hardcoded examples. Missing or ambiguous intent,
ticket selection or reason requires clarification. A research finding alone never
authorizes closure. Explicit closure requests need no second confirmation.
Closure covers all open company tickets, including resolved company email threads,
as on the existing company closure page. The service resolves scope and live Jira
states before writing, validates the option on every open Supplier Onboarding ticket,
then handles the selected onboarding ticket first. It sets the result, adds the user's
explanation as a Jira comment, finds the unique transition to a Done-category status,
closes the ticket and synchronizes its local status. Remaining open tickets receive
the explanation and close in turn; other open onboarding tickets also receive the
result. Already closed tickets are skipped. Failure on the selected onboarding stops
the batch; later failures are collected while other tickets proceed. A partial result
returns isError: true, confirmed closures, failures and tickets not attempted after
an onboarding failure, never an all-success claim.
External writes are not atomic; after any uncertainty inspect Jira before a new request.
If all onboarding tickets are already closed, a new explicit request can close the
remaining work using an existing onboarding task_id and an empty reason_option_id;
the user's reason remains mandatory and closed results are never overwritten.
The stored company record is retained.
Before sending a possible write to /mcp, BE persists write_attempted: true in the existing
turn's result JSON. A failed or interrupted turn with this marker cannot be retried:
the user must inspect the company profile and continue with a new question. Identical
write arguments within one running turn reuse their first result. After an uncertain
write error, further writes in that turn are blocked. Completed answers retain tool
results in result.actions, along with the marker. This needs no new database schema.
This is deliberately conservative: a marked turn can be blocked even if its write never
committed. It prevents blind replay; it is not global deduplication across conversations.
Except for send_email, MCP does not deduplicate external writes. External clients must inspect the profile after
an uncertain result and must not blindly repeat a call. Name-only people or concurrent
requests can still create duplicates. Durable operation IDs would be needed for
unattended write retries.
Shared-mailbox email
send_email sends a new outbound email from velkoobchody@internet-handel.cz.
There is no sender/account override. Google verifies the shared mailbox identity before
sending. source_email_id provides context; it does not set reply headers or keep the
new message in that Gmail thread. True replies continue through the existing reply form.
Arguments (external MCP also requires company_id):
| Field | Value |
|---|---|
operation_key |
Stable client-generated key, 1–128 ASCII letters/digits/:_-. Reuse unchanged for retries. Pippa injects pippa-turn-<turn ID>; the model cannot supply it. |
recipient_emails |
1–50 distinct bare email addresses. Each must resolve to the selected company through existing contact/domain resolution. An unknown/ambiguous address must first be linked to that company. |
subject, body_text |
Required subject (up to 998 characters, no control characters) and plain text (up to 32,000). |
body_html |
Optional formatted alternative, up to 48,000 characters; empty string if unused. The actor's profile signature is appended by the server. |
source_email_id |
Existing company thread ID, or empty string. |
task_id |
Existing editable, open email_thread_reply ticket linked to that source thread, or 0 to create one. |
attachment_source_ids |
Existing company file IDs from list_email_attachments (or Pippa's list_sources), otherwise []. Up to 10 files, 10 MiB each, 20 MiB total. |
purpose |
correspondence for ordinary supplier communication; initial_outreach for onboarding first contact. |
All arguments are required in the strict schema; optional values use the empty values above. The existing MCP request limit is 64 KiB, including JSON and all text fields; the individual text maxima cannot all be used simultaneously. Attachment bytes are loaded by the backend through Google/Jira, never passed through the model or MCP JSON. References are resolved against a fresh company manifest. Email attachment IDs use the provider path rather than list position, so reordered lists cannot select a different file. Files need not be readable by the AI to be attached. New local files can first be uploaded to the company's Jira ticket using the existing upload form; this feature does not add a chat upload endpoint or accept arbitrary paths/URLs/base64.
Full company access and current company-write scope are required, including on replay.
Task-derived contact collaboration alone cannot send. Existing task IDs also require
task edit access and canonical company membership. Before Gmail, a new ticket's creator
is resolved through the actor's Jira OAuth /myself (including the existing token-refresh
flow); Reporter is explicitly set to that Jira account. If the personal grant is missing,
invalid/revoked, cannot be refreshed because it is invalid, or belongs to an inactive Jira
account, the configured application Jira account is validated and used as both creator
and Reporter. The same selected account updates the new ticket and moves it to
Waiting for Reply. A WARN pippa_email_jira_application_fallback records the requesting
application user ID, created Jira key/ID, email thread, application Jira account ID and
reason immediately after Jira creation, even if a later local write fails. Tokens and
email bodies are not logged. Successful new-ticket results include
jira_application_fallback and jira_reporter_account_id.
The Jira issue's create screen must expose Reporter and the selected Jira account must
be allowed to set it (Atlassian Reporter permissions);
a rejection remains a Jira error and never causes an email resend.
Jira permission errors, outages and ambiguous write failures do not trigger a retry under
another account. Existing tickets retain their Reporter and require the actor's personal
Jira grant for reads/transitions. This fallback is limited to creating a new communication
ticket and its follow-up updates; attachment reads and live onboarding checks retain
their existing delegated-access requirements. The requesting user still owns the send
operation and supplies its signature and application permissions.
A new ticket uses the company's default salesperson, then the configured global
default, then the actor when Jira-assignable; absence of an assignee blocks sending.
The new thread is imported through the existing email importer. A selected communication
ticket receives the new thread; otherwise a communication ticket is created. The ticket
is then moved to Waiting for Reply. Long email subjects are shortened only in the
new Jira summary. Company membership remains derived from canonical thread resolution;
this tool introduces no manual thread/company link.
Initial outreach additionally checks current CME registration and every visible Supplier Onboarding ticket's live Jira state. An active CME supplier, a closed onboarding ticket, or absence of an onboarding ticket blocks this purpose. Ordinary correspondence remains available for an existing supplier. Pippa's instructions prohibit relabeling initial outreach to bypass this rule. These tools are excluded from onboarding/scouting chats; onboarding retains its dedicated approval button.
Only an explicit send request authorizes Pippa to call the tool; asking for a draft
produces text in chat. Missing/ambiguous recipients, content or attachments require
clarification. One Pippa turn can send one email (possibly to multiple recipients).
The server commits a claim to mcp_email_send before Gmail. The unique actor/key pair
serializes concurrent calls; a key reused with different company/content is rejected.
Replaying the same request returns the stored outcome without calling Gmail/Jira again.
The claim survives BE restarts and conversation deletion. It is not deduplication across
different operation keys, users or conversations, nor a promise of exactly-once delivery.
Results include sender, recipients, subject, company link, and confirmed Gmail/task IDs
and links when available. email_status=sent confirms Gmail acceptance. isError=true
with sent reports unfinished canonical fetch/import/Jira work; phase identifies it.
email_status=unknown means an in-progress/ambiguous send (including a crash after claim).
Neither outcome permits automatic resend, even with a new key. Validation/access errors
occur before Gmail. Operators inspect Sent mail and Jira to finish partial work manually;
replaying the tool does not resume post-send work.
Release migration 0094, deploy BE, and update both the allowlist and email instructions
in any server-owned conf/pippa.yaml before restarting BE. No new runtime secret or
permission is introduced. Sending verification uses mocked providers; local integration
tests exercise the real MCP HTTP transport and PostgreSQL claim concurrency.
Personal bearer authentication
There is one endpoint, /mcp, with Streamable HTTP. External clients use
https://reply-pilot.mathbox.90.cz/mcp once the exact path is routed to BE by HAProxy.
Pippa uses loopback HTTP in the BE process. Internal clients can use
http://reply-pilot-be:5000/mcp; the default local host mapping is port 9091.
Every request requires Authorization: Bearer <personal-token>.
Both token types, pippa and other, authenticate identically: hash the supplied
token with SHA-256, look up its stored hash, check revocation and expiration, then
load the owner's current permissions including mcp.access. Existing company/task
permissions still govern every tool. Token type does not grant additional rights.
The old shared MCP_API_TOKEN and actor/resource delegation headers are rejected.
Invalid or inactive credentials return 401. Browser Origin headers are rejected.
Transport Host validation uses MCP_ALLOWED_HOSTS; requests are limited to 64 KiB.
Token management
Profile → Tokens (/profil/tokens) lists only the current user's tokens: name, type,
created date, expiration, last use and status. Add a token with a name, type and either
an expiration date or explicit Bez expirace. Expiration is inclusive through the
selected date in APP_TIMEZONE (default Europe/Prague); null means no expiration.
Creation redirects to the list and reveals the new value. Later visits mask values;
Zobrazit and Kopírovat retrieve the original on demand. Revocation uses an
explicit confirmation dialog and takes effect on subsequent requests. It does not
cancel an already running tool. Token pages and responses use Cache-Control: no-store.
Manual token management remains unavailable during impersonation.
Opening a Pippa page (company chat, onboarding, scouting or own history) calls
POST /api/pippa/access from the private web tier. After checking the effective
user's current mcp.access permission, BE reuses the newest active pippa token or
creates one named Pippa, without expiration. Simultaneous opens serialize on
the owner's DB row and recheck before inserting, so they create only one token.
Existing valid tokens keep their name and expiration. Expired and revoked tokens
remain unchanged; a new one is created only when no active Pippa token exists.
The encrypted original and SHA-256 hash use the same storage as manually created
credentials. No token value is returned to the browser by this access endpoint.
During impersonation, this automatic initialization belongs to the effective user,
never the administrator. It does not grant permission to reveal or manually manage
that user's credentials. Missing mcp.access still refuses access. A decryption or
configuration failure is reported instead of silently replacing an active credential.
Submissions, polling (GET /api/pippa/access), queued work and shared MCP tools only
validate existing tokens; they do not automatically replace credentials in background
work. Queued work rechecks before reading sources and before completion. Revoking the
last active Pippa token stops subsequent checks until the user opens/reloads a Pippa
page, which creates a replacement if permission still allows it. Remove mcp.access
to disable Pippa access itself. Direct /mcp calls never provision credentials.
The admin history screens retain their existing admin authorization. Tokens never
enter prompts, model context or tool results.
Storage and rollout
Migration 0092 adds app_user_mcp_token and seeds mcp.access for admin, sales,
sales_int and loader. Tokens contain 32 cryptographically random bytes. Each row
stores a SHA-256 hash and an AES-256-GCM encrypted original. The encryption binds the
envelope to the owner and hash. Originals for both types remain recoverable for the
owner; Pippa decrypts its original in BE before sending the bearer to /mcp.
MCP_TOKEN_ENCRYPTION_KEY is base64 of 32 random bytes, held outside DB in
secrets/local/reply-pilot-be.env and secrets/prod/reply-pilot-be.env, with separate
keys per environment. Keep the key when redeploying or restoring DB; replacing it
without re-encrypting records prevents reveal and Pippa use of existing tokens.
An absent key blocks create/reveal/Pippa with 503; hash-only authentication still works.
MCP_ALLOWED_HOSTS is a comma-separated host/port allowlist: local defaults are
localhost:*,127.0.0.1:*,reply-pilot-be:*; production also includes
reply-pilot.mathbox.90.cz:*.
Deploy migration 0092 before BE and APP, materialize their SOPS environment and
restart BE. Opening Pippa initializes its personal token; users create tokens for
external clients in their profile. Existing shared tokens do not migrate.
External clients replace their shared token and remove delegation headers. Route only
the exact public /mcp path to BE over HTTPS, preserving Authorization and keeping
all backend /api/* routes private. The token-management REST API trusts the private
web tier's identity headers and must never be exposed directly by that proxy rule.
See HAProxy workflow. /mcp/external is not a
separate server and is not registered by this implementation.
Protocol and results
Clients initialize normally, send notifications/initialized, and negotiate a
supported MCP protocol version. Send Accept: application/json, text/event-stream.
For example, after initialization:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "create_company_person",
"arguments": {
"company_id": 676,
"full_name": "Test Person",
"contacts": [{"type": "EMAIL", "value": "person@example.test"}]
}
}
}
For a company mailbox, call add_company_contact with
{"company_id":676,"contact":{"type":"EMAIL","value":"info@example.test"}}.
For a person's role at that company, call update_company_person_role with
{"company_id":676,"person_id":42,"job_title":"Obchodní zástupce","role_description":"Specializace na produkty Dell"}.
Use actual IDs and current role values from get_company; Pippa supplies company_id
from its conversation while external MCP clients include it explicitly.
Successful calls return structured JSON; errors return isError=true with a safe
message. Logs record tool, actor, company and created record IDs without contact
values or tokens. BE's existing /healthz, non-root container and stdout/stderr
logging apply. Deploy/start/stop only the existing BE module.
Verification
From reply-pilot-be, run mvn test. Tests cover the real HTTP servlet and SDK
protocol, authentication and request scope, shared tool authorization and validation,
configuration, Pippa dispatch and repeated-write prevention. Set PIPPA_TEST_DB_URL,
PIPPA_TEST_DB_USER and PIPPA_TEST_DB_PASSWORD to include disposable-schema PostgreSQL
checks of token encryption, hash authentication, ownership, expiry, revocation and
the persistent retry/restart guard. APP checks are pytest tests/test_tokens.py.
No paid model call is required.
Scouting behavior
check_companies accepts 1–20 companies with company_name, website,
registration_country_code, company_registration_number (Czech IČO only) and
identifiers (the same country/registry-scoped foreign identifiers as onboarding).
Unknown strings/arrays are empty. It searches the authorized company index by
website host and name and also checks current DB records, including hidden/new
companies, plus permanent candidate decisions. Hidden matches return only a
blocking notice; no private profile IDs or details. Search results are possible
matches, not identity proof. A search outage is explicitly reported and never
turns into a claim that a company does not exist.
save_scout_candidates accepts the agreed search country and 1–20 candidates
with those identity fields plus why_relevant, b2b_signal and public sources
URLs. The handler always performs verification; the model cannot supply its own
eligibility verdict. Scouting has no company/contact/closure mutation tools.
decide_scout_candidate takes a stored candidate_id, decision (reject,
reopen, skip_search, restore_search) and a nonempty user reason. Decisions
remain after admin chat deletion. The normal persistent write-attempt guard also
covers scouting writes; after a failed partial batch inspect saved candidates
and continue in a new turn, never replay the whole write blindly.
Deploy migration 0091 before BE and APP. Update an existing server-owned
conf/pippa.yaml with the three tools and scouting instructions from the shipped
configuration, then restart BE; deploying the fallback alone does not enable them.
Scouting adds no separate service. It uses the personal Pippa token and shared
authentication configuration described above.